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,38 @@
1
+ """Cancellation signalling shared between the scheduler and process runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+
7
+
8
+ class CancellationToken:
9
+ """A one-shot cancellation signal with an associated reason.
10
+
11
+ The token is safe to share across threads. Once cancelled it stays
12
+ cancelled; retries observe the token and suppress further attempts.
13
+ """
14
+
15
+ def __init__(self) -> None:
16
+ self._event = threading.Event()
17
+ self._lock = threading.Lock()
18
+ self._reason = ""
19
+
20
+ def cancel(self, reason: str = "") -> None:
21
+ with self._lock:
22
+ if not self._reason and reason:
23
+ self._reason = reason
24
+ self._event.set()
25
+
26
+ @property
27
+ def is_cancelled(self) -> bool:
28
+ return self._event.is_set()
29
+
30
+ @property
31
+ def reason(self) -> str:
32
+ with self._lock:
33
+ return self._reason
34
+
35
+ @property
36
+ def event(self) -> threading.Event:
37
+ """The underlying event, suitable for passing to the process runner."""
38
+ return self._event
@@ -0,0 +1,93 @@
1
+ """Execution events and a minimal synchronous event bus.
2
+
3
+ Events decouple the engine from presentation. Listeners must be fast and must
4
+ not raise; the bus isolates listener exceptions so one bad subscriber cannot
5
+ corrupt the scheduler.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from dataclasses import dataclass, field
12
+ from typing import Callable, List, Optional
13
+
14
+ from pytaskwirecheck.engine.state import TaskStatus
15
+
16
+
17
+ @dataclass
18
+ class Event:
19
+ """Base class for all engine events."""
20
+
21
+
22
+ @dataclass
23
+ class RunStarted(Event):
24
+ run_id: str
25
+ tasks: tuple
26
+ total_tasks: int
27
+
28
+
29
+ @dataclass
30
+ class TaskStateChanged(Event):
31
+ task_id: str
32
+ status: TaskStatus
33
+ message: str = ""
34
+
35
+
36
+ @dataclass
37
+ class TaskAttemptStarted(Event):
38
+ task_id: str
39
+ attempt: int
40
+ max_attempts: int
41
+
42
+
43
+ @dataclass
44
+ class TaskAttemptFinished(Event):
45
+ task_id: str
46
+ attempt: int
47
+ status: TaskStatus
48
+ exit_code: Optional[int]
49
+ duration_seconds: float
50
+
51
+
52
+ @dataclass
53
+ class TaskOutput(Event):
54
+ task_id: str
55
+ stream: str
56
+ data: bytes
57
+
58
+
59
+ @dataclass
60
+ class RunFinished(Event):
61
+ run_id: str
62
+ succeeded: bool
63
+ summary: dict = field(default_factory=dict)
64
+
65
+
66
+ Listener = Callable[[Event], None]
67
+
68
+
69
+ class EventBus:
70
+ """A thread-safe, synchronous publish/subscribe bus."""
71
+
72
+ def __init__(self) -> None:
73
+ self._listeners: List[Listener] = []
74
+ self._lock = threading.Lock()
75
+
76
+ def subscribe(self, listener: Listener) -> None:
77
+ with self._lock:
78
+ self._listeners.append(listener)
79
+
80
+ def unsubscribe(self, listener: Listener) -> None:
81
+ with self._lock:
82
+ if listener in self._listeners:
83
+ self._listeners.remove(listener)
84
+
85
+ def emit(self, event: Event) -> None:
86
+ with self._lock:
87
+ listeners = list(self._listeners)
88
+ for listener in listeners:
89
+ try:
90
+ listener(event)
91
+ except Exception:
92
+ # Presentation must never break execution.
93
+ continue
@@ -0,0 +1,301 @@
1
+ """Per-task execution: environment, timeout, retries, and cache integration.
2
+
3
+ The executor runs exactly one task to a terminal state. It is driven by the
4
+ scheduler one call per task; it does not know about other tasks except through
5
+ the dependency cache keys passed in by the scheduler.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import time
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Mapping, Optional, Protocol
15
+
16
+ from pytaskwirecheck.config.schema import TaskConfig
17
+ from pytaskwirecheck.diagnostics.errors import ProcessStartError
18
+ from pytaskwirecheck.engine.cancellation import CancellationToken
19
+ from pytaskwirecheck.engine.events import (
20
+ EventBus,
21
+ TaskAttemptFinished,
22
+ TaskAttemptStarted,
23
+ TaskOutput,
24
+ )
25
+ from pytaskwirecheck.engine.state import TaskStatus
26
+ from pytaskwirecheck.processes.runner import CommandSpec, ProcessResult, run_command
27
+
28
+
29
+ @dataclass
30
+ class TaskOutcome:
31
+ """The terminal outcome of executing a single task."""
32
+
33
+ task_id: str
34
+ status: TaskStatus
35
+ exit_code: Optional[int] = None
36
+ attempts: int = 0
37
+ duration_seconds: float = 0.0
38
+ message: str = ""
39
+ cache_hit: bool = False
40
+ cache_key: Optional[str] = None
41
+ output_bytes: int = 0
42
+
43
+
44
+ class CacheBackend(Protocol):
45
+ """Structural interface for the content-addressed cache (Phase 3)."""
46
+
47
+ def try_restore(
48
+ self, task: TaskConfig, dependency_keys: Mapping[str, Optional[str]]
49
+ ) -> Optional[str]:
50
+ """Return a cache key if a valid entry was restored, else ``None``."""
51
+
52
+ def store(
53
+ self, task: TaskConfig, dependency_keys: Mapping[str, Optional[str]]
54
+ ) -> Optional[str]:
55
+ """Commit a successful result; return the cache key (or ``None``)."""
56
+
57
+
58
+ def build_environment(
59
+ task: TaskConfig, base_env: Mapping[str, str]
60
+ ) -> Mapping[str, str]:
61
+ """Compose the subprocess environment for ``task``.
62
+
63
+ Secret values are never logged; only the set of overridden names is ever
64
+ exposed to presentation.
65
+ """
66
+ env = dict(base_env) if task.inherit_env else {}
67
+ env.update(task.env)
68
+ return env
69
+
70
+
71
+ def resolve_cwd(root: Path, task: TaskConfig) -> Path:
72
+ if task.cwd:
73
+ return (root / task.cwd).resolve()
74
+ return root
75
+
76
+
77
+ class TaskExecutor:
78
+ """Runs a single task with retries and optional cache integration."""
79
+
80
+ def __init__(
81
+ self,
82
+ *,
83
+ root: Path,
84
+ default_timeout: Optional[float],
85
+ events: EventBus,
86
+ cancel_token: CancellationToken,
87
+ base_env: Optional[Mapping[str, str]] = None,
88
+ cache: Optional[CacheBackend] = None,
89
+ grace_seconds: float = 5.0,
90
+ ) -> None:
91
+ self._root = root
92
+ self._default_timeout = default_timeout
93
+ self._events = events
94
+ self._cancel = cancel_token
95
+ self._base_env = base_env if base_env is not None else os.environ
96
+ self._cache = cache
97
+ self._grace = grace_seconds
98
+
99
+ def effective_timeout(self, task: TaskConfig) -> Optional[float]:
100
+ if task.timeout_seconds is not None:
101
+ return task.timeout_seconds
102
+ return self._default_timeout
103
+
104
+ def _interruptible_sleep(self, seconds: float) -> bool:
105
+ """Sleep up to ``seconds``; return ``False`` if cancellation occurred."""
106
+ remaining = seconds
107
+ step = 0.1
108
+ while remaining > 0:
109
+ if self._cancel.is_cancelled:
110
+ return False
111
+ time.sleep(min(step, remaining))
112
+ remaining -= step
113
+ return not self._cancel.is_cancelled
114
+
115
+ def execute(
116
+ self,
117
+ task: TaskConfig,
118
+ dependency_keys: Optional[Mapping[str, Optional[str]]] = None,
119
+ *,
120
+ use_cache: bool = True,
121
+ ) -> TaskOutcome:
122
+ """Execute ``task`` and return its terminal :class:`TaskOutcome`."""
123
+ dependency_keys = dependency_keys or {}
124
+
125
+ if self._cancel.is_cancelled:
126
+ return TaskOutcome(
127
+ task_id=task.id,
128
+ status=TaskStatus.CANCELLED,
129
+ message=self._cancel.reason or "cancelled before start",
130
+ )
131
+
132
+ if use_cache and self._cache is not None and task.cache_eligible:
133
+ key = self._cache.try_restore(task, dependency_keys)
134
+ if key is not None:
135
+ return TaskOutcome(
136
+ task_id=task.id,
137
+ status=TaskStatus.CACHED,
138
+ cache_hit=True,
139
+ cache_key=key,
140
+ message="restored from cache",
141
+ )
142
+
143
+ spec = CommandSpec(
144
+ argv=task.command,
145
+ cwd=resolve_cwd(self._root, task),
146
+ env=build_environment(task, self._base_env),
147
+ shell=task.shell,
148
+ timeout_seconds=self.effective_timeout(task),
149
+ max_output_bytes=task.resources.max_output_bytes,
150
+ )
151
+
152
+ max_attempts = max(1, task.retry.max_attempts)
153
+ attempt = 0
154
+ total_duration = 0.0
155
+ last_exit: Optional[int] = None
156
+
157
+ while attempt < max_attempts:
158
+ if self._cancel.is_cancelled:
159
+ return TaskOutcome(
160
+ task_id=task.id,
161
+ status=TaskStatus.CANCELLED,
162
+ attempts=attempt,
163
+ duration_seconds=total_duration,
164
+ exit_code=last_exit,
165
+ message=self._cancel.reason or "cancelled",
166
+ )
167
+ attempt += 1
168
+ self._events.emit(
169
+ TaskAttemptStarted(
170
+ task_id=task.id, attempt=attempt, max_attempts=max_attempts
171
+ )
172
+ )
173
+
174
+ def _on_output(stream: str, chunk: bytes, _id: str = task.id) -> None:
175
+ self._events.emit(TaskOutput(task_id=_id, stream=stream, data=chunk))
176
+
177
+ started = time.monotonic()
178
+ try:
179
+ result = run_command(
180
+ spec,
181
+ on_output=_on_output,
182
+ cancel_event=self._cancel.event,
183
+ grace_seconds=self._grace,
184
+ )
185
+ except ProcessStartError as exc:
186
+ total_duration += time.monotonic() - started
187
+ self._events.emit(
188
+ TaskAttemptFinished(
189
+ task_id=task.id,
190
+ attempt=attempt,
191
+ status=TaskStatus.FAILED,
192
+ exit_code=None,
193
+ duration_seconds=total_duration,
194
+ )
195
+ )
196
+ return TaskOutcome(
197
+ task_id=task.id,
198
+ status=TaskStatus.FAILED,
199
+ attempts=attempt,
200
+ duration_seconds=total_duration,
201
+ message=str(exc),
202
+ )
203
+
204
+ duration = result.duration_seconds
205
+ total_duration += duration
206
+ last_exit = result.exit_code
207
+ status = self._classify(result)
208
+ self._events.emit(
209
+ TaskAttemptFinished(
210
+ task_id=task.id,
211
+ attempt=attempt,
212
+ status=status,
213
+ exit_code=result.exit_code,
214
+ duration_seconds=duration,
215
+ )
216
+ )
217
+
218
+ if status is TaskStatus.SUCCEEDED:
219
+ cache_key = None
220
+ if use_cache and self._cache is not None and task.cache_eligible:
221
+ cache_key = self._cache.store(task, dependency_keys)
222
+ return TaskOutcome(
223
+ task_id=task.id,
224
+ status=TaskStatus.SUCCEEDED,
225
+ exit_code=result.exit_code,
226
+ attempts=attempt,
227
+ duration_seconds=total_duration,
228
+ cache_key=cache_key,
229
+ output_bytes=result.output_bytes,
230
+ )
231
+
232
+ if status is TaskStatus.CANCELLED:
233
+ return TaskOutcome(
234
+ task_id=task.id,
235
+ status=TaskStatus.CANCELLED,
236
+ exit_code=result.exit_code,
237
+ attempts=attempt,
238
+ duration_seconds=total_duration,
239
+ message=self._cancel.reason or "cancelled",
240
+ )
241
+
242
+ # Decide whether to retry.
243
+ retryable = self._is_retryable(task, result, status)
244
+ if not retryable or attempt >= max_attempts:
245
+ return TaskOutcome(
246
+ task_id=task.id,
247
+ status=status,
248
+ exit_code=result.exit_code,
249
+ attempts=attempt,
250
+ duration_seconds=total_duration,
251
+ output_bytes=result.output_bytes,
252
+ message=self._failure_message(result, status),
253
+ )
254
+ delay = task.retry.delay_for_attempt(attempt + 1)
255
+ if delay > 0 and not self._interruptible_sleep(delay):
256
+ return TaskOutcome(
257
+ task_id=task.id,
258
+ status=TaskStatus.CANCELLED,
259
+ attempts=attempt,
260
+ duration_seconds=total_duration,
261
+ message=self._cancel.reason or "cancelled during retry delay",
262
+ )
263
+
264
+ # Unreachable: loop always returns. Defensive fallback.
265
+ return TaskOutcome(
266
+ task_id=task.id,
267
+ status=TaskStatus.FAILED,
268
+ exit_code=last_exit,
269
+ attempts=attempt,
270
+ duration_seconds=total_duration,
271
+ message="exhausted attempts",
272
+ )
273
+
274
+ @staticmethod
275
+ def _classify(result: ProcessResult) -> TaskStatus:
276
+ if result.cancelled:
277
+ return TaskStatus.CANCELLED
278
+ if result.timed_out:
279
+ return TaskStatus.TIMED_OUT
280
+ if result.exit_code == 0:
281
+ return TaskStatus.SUCCEEDED
282
+ return TaskStatus.FAILED
283
+
284
+ def _is_retryable(
285
+ self, task: TaskConfig, result: ProcessResult, status: TaskStatus
286
+ ) -> bool:
287
+ if not task.retry.enabled:
288
+ return False
289
+ if status is TaskStatus.TIMED_OUT:
290
+ return True
291
+ if status is TaskStatus.FAILED and result.exit_code is not None:
292
+ return task.retry.should_retry_exit_code(result.exit_code)
293
+ return False
294
+
295
+ @staticmethod
296
+ def _failure_message(result: ProcessResult, status: TaskStatus) -> str:
297
+ if status is TaskStatus.TIMED_OUT:
298
+ return "timed out"
299
+ if result.exit_code is not None:
300
+ return "exited with code %d" % result.exit_code
301
+ return "failed"