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,71 @@
|
|
|
1
|
+
"""HTTP webhook notifications.
|
|
2
|
+
|
|
3
|
+
The webhook notifier is opt-in and disabled by default. It uses only the
|
|
4
|
+
standard library, applies an explicit network timeout, and retries a bounded
|
|
5
|
+
number of times with a short delay. Failures are reported as ``False`` (and
|
|
6
|
+
optionally as an error string); they never raise into the run.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from typing import List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from pytaskwirecheck.notifications.base import RunNotification
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WebhookNotifier:
|
|
21
|
+
"""Posts run notifications as JSON to a single HTTP endpoint."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
url: str,
|
|
26
|
+
*,
|
|
27
|
+
timeout_seconds: float = 5.0,
|
|
28
|
+
max_attempts: int = 2,
|
|
29
|
+
retry_delay_seconds: float = 0.5,
|
|
30
|
+
events: Tuple[str, ...] = ("failure",),
|
|
31
|
+
) -> None:
|
|
32
|
+
if not url:
|
|
33
|
+
raise ValueError("webhook url must not be empty")
|
|
34
|
+
self._url = url
|
|
35
|
+
self._timeout = timeout_seconds
|
|
36
|
+
self._max_attempts = max(1, max_attempts)
|
|
37
|
+
self._retry_delay = retry_delay_seconds
|
|
38
|
+
self._events = events
|
|
39
|
+
self.errors: List[str] = []
|
|
40
|
+
|
|
41
|
+
def wants(self, event: str) -> bool:
|
|
42
|
+
return event in self._events
|
|
43
|
+
|
|
44
|
+
def send(self, notification: RunNotification) -> bool:
|
|
45
|
+
if not self.wants(notification.event):
|
|
46
|
+
return True
|
|
47
|
+
body = json.dumps(notification.to_payload()).encode("utf-8")
|
|
48
|
+
last_error: Optional[str] = None
|
|
49
|
+
for attempt in range(1, self._max_attempts + 1):
|
|
50
|
+
request = urllib.request.Request(
|
|
51
|
+
self._url,
|
|
52
|
+
data=body,
|
|
53
|
+
headers={"Content-Type": "application/json"},
|
|
54
|
+
method="POST",
|
|
55
|
+
)
|
|
56
|
+
try:
|
|
57
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
58
|
+
status = getattr(response, "status", 200)
|
|
59
|
+
if 200 <= status < 300:
|
|
60
|
+
return True
|
|
61
|
+
last_error = "HTTP %s" % status
|
|
62
|
+
except urllib.error.URLError as exc:
|
|
63
|
+
# Do not leak response bodies; str(exc) may include the URL.
|
|
64
|
+
last_error = "network error: %s" % exc.reason
|
|
65
|
+
except (TimeoutError, OSError) as exc:
|
|
66
|
+
last_error = "network error: %s" % type(exc).__name__
|
|
67
|
+
if attempt < self._max_attempts:
|
|
68
|
+
time.sleep(self._retry_delay)
|
|
69
|
+
if last_error:
|
|
70
|
+
self.errors.append(last_error)
|
|
71
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Subprocess lifecycle management.
|
|
2
|
+
|
|
3
|
+
This module runs a single configured command and reports its outcome. It has no
|
|
4
|
+
knowledge of the DAG, scheduling, caching, or presentation. It is responsible
|
|
5
|
+
for correctness under cancellation, timeouts, large output, and start-up
|
|
6
|
+
failures.
|
|
7
|
+
|
|
8
|
+
Design notes:
|
|
9
|
+
|
|
10
|
+
* Commands are executed as argument arrays. Shell execution is opt-in and
|
|
11
|
+
explicit (``shell=True`` requires a single-element ``argv``).
|
|
12
|
+
* Output is streamed to a callback from dedicated reader threads, which avoids
|
|
13
|
+
the classic pipe-buffer deadlock and keeps memory bounded.
|
|
14
|
+
* On POSIX, each command runs in its own session/process group so that the
|
|
15
|
+
whole descendant tree can be signalled. On Windows a new process group is
|
|
16
|
+
created and descendants are terminated best-effort.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Callable, Mapping, Optional, Sequence, Tuple, Union
|
|
29
|
+
|
|
30
|
+
from pytaskwirecheck.diagnostics.errors import ProcessStartError
|
|
31
|
+
|
|
32
|
+
#: Signature for streaming consumers: (stream_name, chunk) where stream_name is
|
|
33
|
+
#: ``"stdout"`` or ``"stderr"``.
|
|
34
|
+
OutputCallback = Callable[[str, bytes], None]
|
|
35
|
+
|
|
36
|
+
_POLL_INTERVAL = 0.05
|
|
37
|
+
_GRACE_PERIOD_SECONDS = 5.0
|
|
38
|
+
_READ_CHUNK = 65536
|
|
39
|
+
|
|
40
|
+
_IS_WINDOWS = sys.platform.startswith("win")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class CommandSpec:
|
|
45
|
+
"""Everything needed to run one command."""
|
|
46
|
+
|
|
47
|
+
argv: Tuple[str, ...]
|
|
48
|
+
cwd: Path
|
|
49
|
+
env: Mapping[str, str]
|
|
50
|
+
shell: bool = False
|
|
51
|
+
timeout_seconds: Optional[float] = None
|
|
52
|
+
max_output_bytes: Optional[int] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ProcessResult:
|
|
57
|
+
"""The outcome of a single command execution."""
|
|
58
|
+
|
|
59
|
+
exit_code: Optional[int]
|
|
60
|
+
timed_out: bool = False
|
|
61
|
+
cancelled: bool = False
|
|
62
|
+
duration_seconds: float = 0.0
|
|
63
|
+
output_bytes: int = 0
|
|
64
|
+
output_truncated: bool = False
|
|
65
|
+
start_error: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def succeeded(self) -> bool:
|
|
69
|
+
return (
|
|
70
|
+
self.start_error is None
|
|
71
|
+
and not self.timed_out
|
|
72
|
+
and not self.cancelled
|
|
73
|
+
and self.exit_code == 0
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _OutputSink:
|
|
78
|
+
"""Thread-safe, optionally size-bounded output accumulator.
|
|
79
|
+
|
|
80
|
+
The sink forwards every chunk to the consumer callback immediately; the
|
|
81
|
+
byte counter is only used to enforce ``max_output_bytes`` once the limit is
|
|
82
|
+
reached (further bytes are dropped from *recording* but the process is not
|
|
83
|
+
killed, matching typical build-tool behaviour).
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
callback: Optional[OutputCallback],
|
|
89
|
+
limit: Optional[int],
|
|
90
|
+
) -> None:
|
|
91
|
+
self._callback = callback
|
|
92
|
+
self._limit = limit
|
|
93
|
+
self._lock = threading.Lock()
|
|
94
|
+
self.total = 0
|
|
95
|
+
self.truncated = False
|
|
96
|
+
|
|
97
|
+
def write(self, stream: str, chunk: bytes) -> None:
|
|
98
|
+
with self._lock:
|
|
99
|
+
if self._limit is not None:
|
|
100
|
+
remaining = self._limit - self.total
|
|
101
|
+
if remaining <= 0:
|
|
102
|
+
self.truncated = True
|
|
103
|
+
return
|
|
104
|
+
if len(chunk) > remaining:
|
|
105
|
+
chunk = chunk[:remaining]
|
|
106
|
+
self.truncated = True
|
|
107
|
+
self.total += len(chunk)
|
|
108
|
+
if self._callback is not None:
|
|
109
|
+
self._callback(stream, chunk)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _reader(pipe, stream: str, sink: _OutputSink) -> None:
|
|
113
|
+
try:
|
|
114
|
+
while True:
|
|
115
|
+
chunk = pipe.read(_READ_CHUNK)
|
|
116
|
+
if not chunk:
|
|
117
|
+
break
|
|
118
|
+
sink.write(stream, chunk)
|
|
119
|
+
except (ValueError, OSError):
|
|
120
|
+
# Pipe closed during shutdown; treat as end of stream.
|
|
121
|
+
pass
|
|
122
|
+
finally:
|
|
123
|
+
try:
|
|
124
|
+
pipe.close()
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _popen_kwargs(spec: CommandSpec) -> dict:
|
|
130
|
+
kwargs: dict = {}
|
|
131
|
+
if _IS_WINDOWS:
|
|
132
|
+
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
133
|
+
else:
|
|
134
|
+
kwargs["start_new_session"] = True
|
|
135
|
+
return kwargs
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _signal_group(proc: "subprocess.Popen", sig: int, *, force: bool) -> None:
|
|
139
|
+
"""Best-effort delivery of a signal to the process and its descendants."""
|
|
140
|
+
if proc.poll() is not None:
|
|
141
|
+
return
|
|
142
|
+
if _IS_WINDOWS: # pragma: no cover - exercised on Windows only
|
|
143
|
+
if force:
|
|
144
|
+
try:
|
|
145
|
+
subprocess.run(
|
|
146
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
147
|
+
capture_output=True,
|
|
148
|
+
check=False,
|
|
149
|
+
)
|
|
150
|
+
return
|
|
151
|
+
except OSError:
|
|
152
|
+
pass
|
|
153
|
+
proc.terminate()
|
|
154
|
+
return
|
|
155
|
+
try:
|
|
156
|
+
pgid = os.getpgid(proc.pid)
|
|
157
|
+
os.killpg(pgid, sig)
|
|
158
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
159
|
+
try:
|
|
160
|
+
proc.terminate() if not force else proc.kill()
|
|
161
|
+
except OSError:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _terminate(proc: "subprocess.Popen", grace: float) -> None:
|
|
166
|
+
"""Ask politely, then force if needed. Never hangs indefinitely."""
|
|
167
|
+
import signal
|
|
168
|
+
|
|
169
|
+
_signal_group(proc, signal.SIGTERM, force=False)
|
|
170
|
+
try:
|
|
171
|
+
proc.wait(timeout=grace)
|
|
172
|
+
return
|
|
173
|
+
except subprocess.TimeoutExpired:
|
|
174
|
+
pass
|
|
175
|
+
_signal_group(proc, signal.SIGKILL, force=True)
|
|
176
|
+
try:
|
|
177
|
+
proc.wait(timeout=grace)
|
|
178
|
+
except subprocess.TimeoutExpired: # pragma: no cover - extreme fallback
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def run_command(
|
|
183
|
+
spec: CommandSpec,
|
|
184
|
+
*,
|
|
185
|
+
on_output: Optional[OutputCallback] = None,
|
|
186
|
+
cancel_event: Optional[threading.Event] = None,
|
|
187
|
+
grace_seconds: float = _GRACE_PERIOD_SECONDS,
|
|
188
|
+
) -> ProcessResult:
|
|
189
|
+
"""Run ``spec`` to completion and return a :class:`ProcessResult`.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ProcessStartError: if the process cannot be started at all.
|
|
193
|
+
"""
|
|
194
|
+
sink = _OutputSink(on_output, spec.max_output_bytes)
|
|
195
|
+
|
|
196
|
+
if spec.shell:
|
|
197
|
+
if len(spec.argv) != 1:
|
|
198
|
+
raise ProcessStartError("shell execution requires a single command string")
|
|
199
|
+
popen_argv: Union[str, Sequence[str]] = spec.argv[0]
|
|
200
|
+
else:
|
|
201
|
+
popen_argv = list(spec.argv)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
proc = subprocess.Popen( # noqa: S603 - argv, no shell by default
|
|
205
|
+
popen_argv,
|
|
206
|
+
cwd=str(spec.cwd),
|
|
207
|
+
env=dict(spec.env),
|
|
208
|
+
stdout=subprocess.PIPE,
|
|
209
|
+
stderr=subprocess.PIPE,
|
|
210
|
+
stdin=subprocess.DEVNULL,
|
|
211
|
+
shell=spec.shell,
|
|
212
|
+
**_popen_kwargs(spec),
|
|
213
|
+
)
|
|
214
|
+
except FileNotFoundError as exc:
|
|
215
|
+
raise ProcessStartError(
|
|
216
|
+
"executable not found: %s" % (spec.argv[0] if spec.argv else "")
|
|
217
|
+
) from exc
|
|
218
|
+
except PermissionError as exc:
|
|
219
|
+
raise ProcessStartError(
|
|
220
|
+
"permission denied executing: %s" % (spec.argv[0] if spec.argv else "")
|
|
221
|
+
) from exc
|
|
222
|
+
except OSError as exc:
|
|
223
|
+
raise ProcessStartError("failed to start process: %s" % exc) from exc
|
|
224
|
+
|
|
225
|
+
assert proc.stdout is not None and proc.stderr is not None
|
|
226
|
+
stdout_thread = threading.Thread(
|
|
227
|
+
target=_reader, args=(proc.stdout, "stdout", sink), daemon=True
|
|
228
|
+
)
|
|
229
|
+
stderr_thread = threading.Thread(
|
|
230
|
+
target=_reader, args=(proc.stderr, "stderr", sink), daemon=True
|
|
231
|
+
)
|
|
232
|
+
stdout_thread.start()
|
|
233
|
+
stderr_thread.start()
|
|
234
|
+
|
|
235
|
+
start = time.monotonic()
|
|
236
|
+
deadline = (
|
|
237
|
+
start + spec.timeout_seconds if spec.timeout_seconds is not None else None
|
|
238
|
+
)
|
|
239
|
+
timed_out = False
|
|
240
|
+
cancelled = False
|
|
241
|
+
|
|
242
|
+
while proc.poll() is None:
|
|
243
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
244
|
+
cancelled = True
|
|
245
|
+
_terminate(proc, grace_seconds)
|
|
246
|
+
break
|
|
247
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
248
|
+
timed_out = True
|
|
249
|
+
_terminate(proc, grace_seconds)
|
|
250
|
+
break
|
|
251
|
+
time.sleep(_POLL_INTERVAL)
|
|
252
|
+
|
|
253
|
+
exit_code = proc.wait()
|
|
254
|
+
stdout_thread.join(timeout=grace_seconds)
|
|
255
|
+
stderr_thread.join(timeout=grace_seconds)
|
|
256
|
+
duration = time.monotonic() - start
|
|
257
|
+
|
|
258
|
+
return ProcessResult(
|
|
259
|
+
exit_code=exit_code,
|
|
260
|
+
timed_out=timed_out,
|
|
261
|
+
cancelled=cancelled,
|
|
262
|
+
duration_seconds=duration,
|
|
263
|
+
output_bytes=sink.total,
|
|
264
|
+
output_truncated=sink.truncated,
|
|
265
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Signal handling helpers for graceful cancellation.
|
|
2
|
+
|
|
3
|
+
PyTaskWireCheck installs temporary handlers so that Ctrl-C (SIGINT) and SIGTERM set a
|
|
4
|
+
cancellation event rather than abruptly killing the scheduler. Handlers are
|
|
5
|
+
restored on exit to avoid surprising the embedding process.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import signal
|
|
11
|
+
import threading
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from typing import Callable, Iterator, List, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@contextmanager
|
|
17
|
+
def install_cancel_handlers(
|
|
18
|
+
set_cancel: Callable[[], None],
|
|
19
|
+
*,
|
|
20
|
+
signals: Optional[List[int]] = None,
|
|
21
|
+
) -> Iterator[None]:
|
|
22
|
+
"""Register handlers that call ``set_cancel`` on interruption.
|
|
23
|
+
|
|
24
|
+
On the second signal (an impatient Ctrl-C), the default behaviour is
|
|
25
|
+
restored and the signal is re-raised, so a user can always escape.
|
|
26
|
+
"""
|
|
27
|
+
if signals is None:
|
|
28
|
+
signals = [signal.SIGINT, signal.SIGTERM]
|
|
29
|
+
|
|
30
|
+
previous: List[tuple] = []
|
|
31
|
+
fired = {"count": 0}
|
|
32
|
+
|
|
33
|
+
def _handler(signum: int, frame: object) -> None: # pragma: no cover
|
|
34
|
+
fired["count"] += 1
|
|
35
|
+
if fired["count"] == 1:
|
|
36
|
+
set_cancel()
|
|
37
|
+
return
|
|
38
|
+
# Second signal: restore defaults and re-raise for a hard exit.
|
|
39
|
+
_restore()
|
|
40
|
+
if signum == signal.SIGINT:
|
|
41
|
+
raise KeyboardInterrupt
|
|
42
|
+
raise SystemExit(128 + signum)
|
|
43
|
+
|
|
44
|
+
def _restore() -> None:
|
|
45
|
+
for signum, old in previous:
|
|
46
|
+
try:
|
|
47
|
+
signal.signal(signum, old)
|
|
48
|
+
except (ValueError, OSError): # pragma: no cover
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
for signum in signals:
|
|
52
|
+
try:
|
|
53
|
+
old = signal.signal(signum, _handler)
|
|
54
|
+
previous.append((signum, old))
|
|
55
|
+
except (ValueError, OSError):
|
|
56
|
+
# Non-main thread or unsupported signal: skip quietly.
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
yield
|
|
60
|
+
finally:
|
|
61
|
+
_restore()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def new_cancel_event() -> threading.Event:
|
|
65
|
+
return threading.Event()
|
pytaskwirecheck/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""A minimal live terminal dashboard.
|
|
2
|
+
|
|
3
|
+
Design constraints (from the specification):
|
|
4
|
+
|
|
5
|
+
* Rendering must never block the scheduler. Events only mutate an in-memory
|
|
6
|
+
snapshot under a lock; a separate daemon thread redraws at a fixed interval.
|
|
7
|
+
* It must degrade safely: when the target stream is not a TTY it does nothing.
|
|
8
|
+
* Redraws use cursor-home + clear-to-end so they do not scroll task logs away.
|
|
9
|
+
* The core engine never imports this module; it is an optional listener.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Dict, Optional, TextIO
|
|
20
|
+
|
|
21
|
+
from pytaskwirecheck.engine.events import (
|
|
22
|
+
Event,
|
|
23
|
+
RunFinished,
|
|
24
|
+
RunStarted,
|
|
25
|
+
TaskStateChanged,
|
|
26
|
+
)
|
|
27
|
+
from pytaskwirecheck.engine.state import TaskStatus
|
|
28
|
+
|
|
29
|
+
_HEAVY = TaskStatus.RUNNING
|
|
30
|
+
_CLEAR = "\033[2J"
|
|
31
|
+
_HOME = "\033[H"
|
|
32
|
+
|
|
33
|
+
_SYMBOL = {
|
|
34
|
+
TaskStatus.PENDING: "·",
|
|
35
|
+
TaskStatus.READY: "·",
|
|
36
|
+
TaskStatus.RUNNING: "▶",
|
|
37
|
+
TaskStatus.SUCCEEDED: "✓",
|
|
38
|
+
TaskStatus.CACHED: "✓",
|
|
39
|
+
TaskStatus.FAILED: "✗",
|
|
40
|
+
TaskStatus.TIMED_OUT: "✗",
|
|
41
|
+
TaskStatus.BLOCKED: "✗",
|
|
42
|
+
TaskStatus.CANCELLED: "⊘",
|
|
43
|
+
TaskStatus.SKIPPED: "⊘",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class _TaskView:
|
|
49
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
50
|
+
attempt: int = 1
|
|
51
|
+
max_attempts: int = 1
|
|
52
|
+
message: str = ""
|
|
53
|
+
started_at: Optional[float] = None
|
|
54
|
+
finished_at: Optional[float] = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class _Snapshot:
|
|
59
|
+
run_id: str = ""
|
|
60
|
+
total: int = 0
|
|
61
|
+
started_at: float = field(default_factory=time.monotonic)
|
|
62
|
+
tasks: Dict[str, _TaskView] = field(default_factory=dict)
|
|
63
|
+
finished: bool = False
|
|
64
|
+
succeeded: bool = False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class LiveDashboard:
|
|
68
|
+
"""A thread-safe, event-driven terminal dashboard."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
stream: Optional[TextIO] = None,
|
|
73
|
+
*,
|
|
74
|
+
refresh_interval: float = 0.2,
|
|
75
|
+
color: bool = False,
|
|
76
|
+
) -> None:
|
|
77
|
+
self._stream = stream if stream is not None else sys.stdout
|
|
78
|
+
self._interval = max(0.05, refresh_interval)
|
|
79
|
+
self._snapshot = _Snapshot()
|
|
80
|
+
self._lock = threading.Lock()
|
|
81
|
+
self._stop = threading.Event()
|
|
82
|
+
self._thread: Optional[threading.Thread] = None
|
|
83
|
+
self._color = color
|
|
84
|
+
self._enabled = False
|
|
85
|
+
|
|
86
|
+
# -- listener interface -------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def handle(self, event: Event) -> None:
|
|
89
|
+
with self._lock:
|
|
90
|
+
if isinstance(event, RunStarted):
|
|
91
|
+
self._snapshot = _Snapshot(
|
|
92
|
+
run_id=event.run_id,
|
|
93
|
+
total=event.total_tasks,
|
|
94
|
+
started_at=time.monotonic(),
|
|
95
|
+
)
|
|
96
|
+
self._snapshot.tasks = {task_id: _TaskView() for task_id in event.tasks}
|
|
97
|
+
elif isinstance(event, TaskStateChanged):
|
|
98
|
+
view = self._snapshot.tasks.get(event.task_id, _TaskView())
|
|
99
|
+
now = time.monotonic()
|
|
100
|
+
if event.status is TaskStatus.RUNNING and view.started_at is None:
|
|
101
|
+
view.started_at = now
|
|
102
|
+
if event.status.is_terminal:
|
|
103
|
+
view.finished_at = now
|
|
104
|
+
view.status = event.status
|
|
105
|
+
if event.message:
|
|
106
|
+
view.message = event.message
|
|
107
|
+
self._snapshot.tasks[event.task_id] = view
|
|
108
|
+
elif isinstance(event, RunFinished):
|
|
109
|
+
self._snapshot.finished = True
|
|
110
|
+
self._snapshot.succeeded = event.succeeded
|
|
111
|
+
|
|
112
|
+
# -- lifecycle ----------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def start(self) -> None:
|
|
115
|
+
try:
|
|
116
|
+
self._enabled = bool(self._stream.isatty())
|
|
117
|
+
except (ValueError, AttributeError):
|
|
118
|
+
self._enabled = False
|
|
119
|
+
if not self._enabled:
|
|
120
|
+
return
|
|
121
|
+
self._stop.clear()
|
|
122
|
+
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
123
|
+
self._thread.start()
|
|
124
|
+
|
|
125
|
+
def stop(self) -> None:
|
|
126
|
+
self._stop.set()
|
|
127
|
+
if self._thread is not None:
|
|
128
|
+
self._thread.join(timeout=2.0)
|
|
129
|
+
self._thread = None
|
|
130
|
+
if self._enabled:
|
|
131
|
+
self._write(self._render())
|
|
132
|
+
self._write("\n")
|
|
133
|
+
|
|
134
|
+
def _loop(self) -> None:
|
|
135
|
+
while not self._stop.wait(self._interval):
|
|
136
|
+
try:
|
|
137
|
+
self._write(_HOME + self._render())
|
|
138
|
+
except (BrokenPipeError, ValueError):
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
# -- rendering ----------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def _counts(self) -> Dict[str, int]:
|
|
144
|
+
counts: Dict[str, int] = {}
|
|
145
|
+
for view in self._snapshot.tasks.values():
|
|
146
|
+
counts[view.status.value] = counts.get(view.status.value, 0) + 1
|
|
147
|
+
return counts
|
|
148
|
+
|
|
149
|
+
def _render(self) -> str:
|
|
150
|
+
with self._lock:
|
|
151
|
+
snapshot = self._snapshot
|
|
152
|
+
total = snapshot.total
|
|
153
|
+
done = sum(1 for view in snapshot.tasks.values() if view.status.is_terminal)
|
|
154
|
+
elapsed = time.monotonic() - snapshot.started_at
|
|
155
|
+
counts = self._counts()
|
|
156
|
+
tasks = sorted(
|
|
157
|
+
snapshot.tasks.items(),
|
|
158
|
+
key=lambda item: (item[1].status.value, item[0]),
|
|
159
|
+
)
|
|
160
|
+
run_id = snapshot.run_id
|
|
161
|
+
|
|
162
|
+
columns, lines = shutil.get_terminal_size((80, 24))
|
|
163
|
+
header = "PyTaskWireCheck run %s %d/%d done %6.1fs" % (
|
|
164
|
+
run_id,
|
|
165
|
+
done,
|
|
166
|
+
total,
|
|
167
|
+
elapsed,
|
|
168
|
+
)
|
|
169
|
+
count_line = " ".join("%d %s" % (counts[key], key) for key in sorted(counts))
|
|
170
|
+
rows = [
|
|
171
|
+
header[:columns],
|
|
172
|
+
count_line[:columns],
|
|
173
|
+
"",
|
|
174
|
+
]
|
|
175
|
+
available = max(1, lines - len(rows) - 1)
|
|
176
|
+
for task_id, view in tasks:
|
|
177
|
+
if len(rows) >= available + 3:
|
|
178
|
+
rows.append(" ... (%d more)" % (len(tasks) - (len(rows) - 3)))
|
|
179
|
+
break
|
|
180
|
+
symbol = _SYMBOL.get(view.status, "?")
|
|
181
|
+
duration = ""
|
|
182
|
+
if view.started_at is not None:
|
|
183
|
+
end = view.finished_at or time.monotonic()
|
|
184
|
+
duration = "%5.1fs" % (end - view.started_at)
|
|
185
|
+
attempt = (
|
|
186
|
+
" (%d/%d)" % (view.attempt, view.max_attempts)
|
|
187
|
+
if view.max_attempts > 1
|
|
188
|
+
else ""
|
|
189
|
+
)
|
|
190
|
+
suffix = ""
|
|
191
|
+
if view.message and view.status.is_failure:
|
|
192
|
+
suffix = " %s" % view.message
|
|
193
|
+
rows.append(
|
|
194
|
+
" %s %-24s %8s%s%s" % (symbol, task_id, duration, attempt, suffix)
|
|
195
|
+
)
|
|
196
|
+
rows.append("")
|
|
197
|
+
return _CLEAR + "\n".join(row[:columns] for row in rows)
|
|
198
|
+
|
|
199
|
+
def _write(self, text: str) -> None:
|
|
200
|
+
try:
|
|
201
|
+
self._stream.write(text)
|
|
202
|
+
self._stream.flush()
|
|
203
|
+
except (BrokenPipeError, ValueError):
|
|
204
|
+
pass
|