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,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()
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