benchkit-cli 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.
- benchkit/__init__.py +1 -0
- benchkit/_pi_proxy.py +219 -0
- benchkit/answer_key_guard.ts +49 -0
- benchkit/artifacts.py +87 -0
- benchkit/benchmarks/__init__.py +105 -0
- benchkit/benchmarks/aider_polyglot.py +405 -0
- benchkit/benchmarks/arc.py +52 -0
- benchkit/benchmarks/banking77.py +149 -0
- benchkit/benchmarks/base.py +19 -0
- benchkit/benchmarks/boolq.py +49 -0
- benchkit/benchmarks/chartqa.py +133 -0
- benchkit/benchmarks/code_verifier.py +77 -0
- benchkit/benchmarks/downloads.py +166 -0
- benchkit/benchmarks/evalplus.py +288 -0
- benchkit/benchmarks/git_surgery.py +485 -0
- benchkit/benchmarks/gpqa.py +56 -0
- benchkit/benchmarks/gsm8k.py +97 -0
- benchkit/benchmarks/hellaswag.py +49 -0
- benchkit/benchmarks/humaneval.py +103 -0
- benchkit/benchmarks/ifeval.py +56 -0
- benchkit/benchmarks/ifeval_instructions.py +477 -0
- benchkit/benchmarks/mbpp.py +60 -0
- benchkit/benchmarks/mc_arena.py +479 -0
- benchkit/benchmarks/mcq.py +94 -0
- benchkit/benchmarks/medfailbench.py +537 -0
- benchkit/benchmarks/mmlu.py +54 -0
- benchkit/benchmarks/mmlu_pro.py +62 -0
- benchkit/benchmarks/mmvp.py +115 -0
- benchkit/benchmarks/ocrbench.py +96 -0
- benchkit/benchmarks/openbookqa.py +52 -0
- benchkit/benchmarks/patcheval.py +929 -0
- benchkit/benchmarks/piqa.py +52 -0
- benchkit/benchmarks/ruler.py +172 -0
- benchkit/benchmarks/ruler_generator.py +535 -0
- benchkit/benchmarks/sanity.py +174 -0
- benchkit/benchmarks/treejs_arena.py +268 -0
- benchkit/benchmarks/truthfulqa.py +52 -0
- benchkit/benchmarks/utils.py +25 -0
- benchkit/benchmarks/vision.py +230 -0
- benchkit/benchmarks/vstar.py +108 -0
- benchkit/benchmarks/winogrande.py +52 -0
- benchkit/benchmarks/xstest.py +116 -0
- benchkit/browser.py +429 -0
- benchkit/catalog.py +121 -0
- benchkit/cli.py +1000 -0
- benchkit/client.py +1413 -0
- benchkit/controls.py +87 -0
- benchkit/datasets/MMLU_LICENSE.txt +21 -0
- benchkit/datasets/README.md +142 -0
- benchkit/datasets/arc_challenge.jsonl +1172 -0
- benchkit/datasets/banking77.jsonl +3080 -0
- benchkit/datasets/banking77_intents.json +79 -0
- benchkit/datasets/boolq.jsonl +3270 -0
- benchkit/datasets/code_verifier.jsonl +500 -0
- benchkit/datasets/gpqa_diamond.jsonl +198 -0
- benchkit/datasets/gsm8k.jsonl +1319 -0
- benchkit/datasets/hellaswag.jsonl +1000 -0
- benchkit/datasets/humaneval.jsonl +164 -0
- benchkit/datasets/ifeval.jsonl +541 -0
- benchkit/datasets/mbpp.jsonl +500 -0
- benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
- benchkit/datasets/medfailbench_hard30.jsonl +30 -0
- benchkit/datasets/medfailbench_hard30.md +13 -0
- benchkit/datasets/openbookqa.jsonl +500 -0
- benchkit/datasets/piqa.jsonl +1838 -0
- benchkit/datasets/sanity_code.jsonl +6 -0
- benchkit/datasets/truthfulqa.jsonl +817 -0
- benchkit/datasets/winogrande.jsonl +1267 -0
- benchkit/datasets/xstest.csv +451 -0
- benchkit/decision.py +194 -0
- benchkit/demo.py +451 -0
- benchkit/dockerfiles.py +247 -0
- benchkit/engine.py +1270 -0
- benchkit/evaluation.py +195 -0
- benchkit/events.py +141 -0
- benchkit/executor.py +96 -0
- benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
- benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
- benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
- benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
- benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
- benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
- benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
- benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
- benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
- benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
- benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
- benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
- benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
- benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
- benchkit/git_surgery/secret-in-history/README.md +27 -0
- benchkit/git_surgery/secret-in-history/setup.sh +107 -0
- benchkit/git_surgery/secret-in-history/verify.sh +106 -0
- benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
- benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
- benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
- benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
- benchkit/history.py +474 -0
- benchkit/jobs.py +332 -0
- benchkit/leakage.py +51 -0
- benchkit/looping.py +873 -0
- benchkit/mc_render.py +274 -0
- benchkit/mc_viewer/dist/atlas.png +0 -0
- benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
- benchkit/mc_viewer/dist/version.json +4 -0
- benchkit/mc_viewer/dist/viewer.html +23 -0
- benchkit/mc_viewer/dist/viewer.js +2 -0
- benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
- benchkit/mc_viewer/dist/worker.js +2 -0
- benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
- benchkit/metrics.py +100 -0
- benchkit/ollama.py +60 -0
- benchkit/perf.py +353 -0
- benchkit/perf_report.py +207 -0
- benchkit/perturbations.py +208 -0
- benchkit/pi_agent.py +476 -0
- benchkit/pi_package/package-lock.json +1827 -0
- benchkit/pi_package/package.json +8 -0
- benchkit/report.py +142 -0
- benchkit/report_markdown.py +645 -0
- benchkit/results.py +688 -0
- benchkit/retry.py +163 -0
- benchkit/runner.py +769 -0
- benchkit/sandbox.py +1167 -0
- benchkit/template_check.py +291 -0
- benchkit/templates/arena.html +504 -0
- benchkit/templates/history.html +58 -0
- benchkit/templates/perf.html +509 -0
- benchkit/templates/report.html +769 -0
- benchkit/tui/__init__.py +5 -0
- benchkit/tui/app.py +87 -0
- benchkit/tui/app.tcss +512 -0
- benchkit/tui/formatting.py +84 -0
- benchkit/tui/screens/__init__.py +1 -0
- benchkit/tui/screens/connect.py +191 -0
- benchkit/tui/screens/detail.py +288 -0
- benchkit/tui/screens/help.py +84 -0
- benchkit/tui/screens/modals.py +544 -0
- benchkit/tui/screens/results.py +361 -0
- benchkit/tui/screens/run.py +988 -0
- benchkit/tui/screens/setup.py +733 -0
- benchkit/tui/theme.py +135 -0
- benchkit/tui/widgets.py +120 -0
- benchkit_cli-0.1.0.dist-info/METADATA +294 -0
- benchkit_cli-0.1.0.dist-info/RECORD +149 -0
- benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
- benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
- benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
benchkit/runner.py
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
"""Headless run rendering for `benchkit --headless`.
|
|
2
|
+
|
|
3
|
+
The TUI is the primary interface; this module keeps a scriptable, log-friendly
|
|
4
|
+
path for CI and remote shells.
|
|
5
|
+
|
|
6
|
+
Two renderings share one event sink:
|
|
7
|
+
|
|
8
|
+
* An interactive terminal gets a live dashboard - the active job with its own
|
|
9
|
+
bar and counters, the streaming task activity, the last few finished tasks
|
|
10
|
+
and an overall bar with an ETA. Notable events (errors, timeouts, loop kills,
|
|
11
|
+
finished jobs) are printed above the dashboard so they stay in scrollback.
|
|
12
|
+
* A pipe, a file or a CI log gets plain timestamped lines instead, because a
|
|
13
|
+
redrawing dashboard is worthless once the escape codes are stripped.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextlib
|
|
19
|
+
import signal
|
|
20
|
+
import time
|
|
21
|
+
from collections import deque
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from types import FrameType
|
|
24
|
+
|
|
25
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
26
|
+
from rich.live import Live
|
|
27
|
+
from rich.text import Text
|
|
28
|
+
|
|
29
|
+
from benchkit.client import InferenceClient
|
|
30
|
+
from benchkit.engine import (
|
|
31
|
+
Engine,
|
|
32
|
+
GenerationProgress,
|
|
33
|
+
JobCompleted,
|
|
34
|
+
JobSpec,
|
|
35
|
+
JobStarted,
|
|
36
|
+
ModelUnloaded,
|
|
37
|
+
RunCompleted,
|
|
38
|
+
RunControls,
|
|
39
|
+
RunFailed,
|
|
40
|
+
RunStarted,
|
|
41
|
+
TaskCompleted,
|
|
42
|
+
TaskPhase,
|
|
43
|
+
TaskRecord,
|
|
44
|
+
slice_label,
|
|
45
|
+
)
|
|
46
|
+
from benchkit.metrics import (
|
|
47
|
+
aggregate_tok_s,
|
|
48
|
+
decision_speed,
|
|
49
|
+
effective_concurrency,
|
|
50
|
+
is_decision,
|
|
51
|
+
stream_tok_s,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
RECENT_TASKS = 5
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _fmt_time(seconds: float) -> str:
|
|
58
|
+
seconds = round(seconds)
|
|
59
|
+
if seconds >= 3600:
|
|
60
|
+
return f"{seconds // 3600}h {seconds % 3600 // 60}m"
|
|
61
|
+
if seconds >= 60:
|
|
62
|
+
return f"{seconds // 60}m {seconds % 60}s"
|
|
63
|
+
return f"{seconds}s"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _plural(count: int, word: str) -> str:
|
|
67
|
+
return f"{count:,} {word}{'' if count == 1 else 's'}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _score_style(score: float) -> str:
|
|
71
|
+
if score >= 80:
|
|
72
|
+
return "green"
|
|
73
|
+
if score < 50:
|
|
74
|
+
return "red"
|
|
75
|
+
return "white"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class _Glyphs:
|
|
80
|
+
"""Drawing characters, degraded for terminals without UTF-8."""
|
|
81
|
+
|
|
82
|
+
spinner: tuple[str, ...]
|
|
83
|
+
full: str
|
|
84
|
+
partial: tuple[str, ...]
|
|
85
|
+
empty: str
|
|
86
|
+
passed: str
|
|
87
|
+
failed: str
|
|
88
|
+
error: str
|
|
89
|
+
dot: str
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def for_console(cls, console: Console) -> _Glyphs:
|
|
93
|
+
encoding = (console.encoding or "").lower().replace("-", "")
|
|
94
|
+
if encoding.startswith("utf") and not console.legacy_windows:
|
|
95
|
+
return cls(
|
|
96
|
+
spinner=("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"),
|
|
97
|
+
full="█",
|
|
98
|
+
partial=("▏", "▎", "▍", "▌", "▋", "▊", "▉"),
|
|
99
|
+
empty="░",
|
|
100
|
+
passed="✓",
|
|
101
|
+
failed="✗",
|
|
102
|
+
error="!",
|
|
103
|
+
dot="·",
|
|
104
|
+
)
|
|
105
|
+
return cls(
|
|
106
|
+
spinner=("|", "/", "-", "\\"),
|
|
107
|
+
full="#",
|
|
108
|
+
partial=(),
|
|
109
|
+
empty=".",
|
|
110
|
+
passed="+",
|
|
111
|
+
failed="x",
|
|
112
|
+
error="!",
|
|
113
|
+
dot="-",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def frame(self) -> str:
|
|
117
|
+
return self.spinner[int(time.monotonic() * 10) % len(self.spinner)]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _bar(
|
|
121
|
+
fraction: float,
|
|
122
|
+
width: int,
|
|
123
|
+
glyphs: _Glyphs,
|
|
124
|
+
*,
|
|
125
|
+
style: str = "white",
|
|
126
|
+
empty_style: str = "bright_black",
|
|
127
|
+
) -> Text:
|
|
128
|
+
"""Render a progress bar, using partial blocks for sub-cell precision."""
|
|
129
|
+
width = max(4, width)
|
|
130
|
+
exact = min(1.0, max(0.0, fraction)) * width
|
|
131
|
+
filled = int(exact)
|
|
132
|
+
bar = Text(glyphs.full * filled, style=style)
|
|
133
|
+
remainder = exact - filled
|
|
134
|
+
if glyphs.partial and filled < width and remainder > 1 / (len(glyphs.partial) + 1):
|
|
135
|
+
index = min(len(glyphs.partial) - 1, int(remainder * len(glyphs.partial)))
|
|
136
|
+
bar.append(glyphs.partial[index], style=style)
|
|
137
|
+
filled += 1
|
|
138
|
+
bar.append(glyphs.empty * (width - filled), style=empty_style)
|
|
139
|
+
return bar
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _spread(left: Text, right: Text, width: int) -> Text:
|
|
143
|
+
"""Left/right justify two runs of text on one line."""
|
|
144
|
+
room = max(0, width - right.cell_len - 1)
|
|
145
|
+
if left.cell_len > room:
|
|
146
|
+
left = left.copy()
|
|
147
|
+
left.truncate(room, overflow="ellipsis")
|
|
148
|
+
line = left.copy()
|
|
149
|
+
line.append(" " * max(1, width - left.cell_len - right.cell_len))
|
|
150
|
+
line.append_text(right)
|
|
151
|
+
return line
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class _LiveStats:
|
|
156
|
+
"""Outcome counters for one headless job."""
|
|
157
|
+
|
|
158
|
+
passed: int = 0
|
|
159
|
+
failed: int = 0
|
|
160
|
+
errors: int = 0
|
|
161
|
+
harness_errors: int = 0
|
|
162
|
+
loops: int = 0
|
|
163
|
+
points: float = 0.0
|
|
164
|
+
|
|
165
|
+
def add(self, record: TaskRecord) -> None:
|
|
166
|
+
if record.passed:
|
|
167
|
+
self.passed += 1
|
|
168
|
+
elif record.error:
|
|
169
|
+
self.errors += 1
|
|
170
|
+
else:
|
|
171
|
+
self.failed += 1
|
|
172
|
+
self.points += record.score if record.score > 0 or not record.passed else 1.0
|
|
173
|
+
self.harness_errors += int(record.harness_error)
|
|
174
|
+
self.loops += int(record.loop_state == "looping")
|
|
175
|
+
|
|
176
|
+
def fields(self, *, live_looping: bool = False) -> dict[str, int]:
|
|
177
|
+
return {
|
|
178
|
+
"passed": self.passed,
|
|
179
|
+
"failed": self.failed,
|
|
180
|
+
"errors": self.errors,
|
|
181
|
+
"loops": self.loops + int(live_looping),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def completed(self) -> int:
|
|
186
|
+
return self.passed + self.failed + self.errors
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def score(self) -> float | None:
|
|
190
|
+
scored = self.completed - self.harness_errors
|
|
191
|
+
return self.points / scored * 100 if scored else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _score_text(score: float | None, width: int = 6) -> Text:
|
|
195
|
+
"""Right aligned live score, dimmed until the first task lands."""
|
|
196
|
+
if score is None:
|
|
197
|
+
return Text("--.-%".rjust(width), style="dim")
|
|
198
|
+
return Text(f"{score:.1f}%".rjust(width), style=f"bold {_score_style(score)}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _counters(stats: _LiveStats, *, live_looping: bool = False) -> Text:
|
|
202
|
+
"""`PASS 9 FAIL 2 ERR 1 LOOP 0`, with zero counts dimmed."""
|
|
203
|
+
fields = stats.fields(live_looping=live_looping)
|
|
204
|
+
text = Text()
|
|
205
|
+
for label, key, style in (
|
|
206
|
+
("PASS", "passed", "green"),
|
|
207
|
+
("FAIL", "failed", "red"),
|
|
208
|
+
("ERR", "errors", "yellow"),
|
|
209
|
+
("LOOP", "loops", "magenta"),
|
|
210
|
+
):
|
|
211
|
+
value = fields[key]
|
|
212
|
+
if text:
|
|
213
|
+
text.append(" ")
|
|
214
|
+
text.append(label, style=f"bold {style}" if value else "dim")
|
|
215
|
+
text.append(f" {value}", style="" if value else "dim")
|
|
216
|
+
return text
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class _JobView:
|
|
221
|
+
"""Live state of the job the engine is currently running."""
|
|
222
|
+
|
|
223
|
+
index: int
|
|
224
|
+
job: JobSpec
|
|
225
|
+
total: int
|
|
226
|
+
completed: int = 0
|
|
227
|
+
stats: _LiveStats = field(default_factory=_LiveStats)
|
|
228
|
+
activity: Text = field(default_factory=Text)
|
|
229
|
+
live_looping: bool = False
|
|
230
|
+
concurrency: int = 1
|
|
231
|
+
looping_positions: set[int] = field(default_factory=set)
|
|
232
|
+
active_positions: set[int] = field(default_factory=set)
|
|
233
|
+
progress_by_position: dict[int, GenerationProgress] = field(default_factory=dict)
|
|
234
|
+
completed_chars: int = 0
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def generated_chars(self) -> int:
|
|
238
|
+
"""Characters generated by completed and currently streaming tasks."""
|
|
239
|
+
return self.completed_chars + sum(
|
|
240
|
+
progress.thinking_chars + progress.response_chars
|
|
241
|
+
for progress in self.progress_by_position.values()
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass
|
|
246
|
+
class _RunState:
|
|
247
|
+
"""Everything the dashboard needs, updated from the engine sink."""
|
|
248
|
+
|
|
249
|
+
jobs: list[JobSpec] = field(default_factory=list)
|
|
250
|
+
overall_total: int = 0
|
|
251
|
+
overall_done: int = 0
|
|
252
|
+
started: float = field(default_factory=time.monotonic)
|
|
253
|
+
current: _JobView | None = None
|
|
254
|
+
recent: deque[Text] = field(default_factory=lambda: deque(maxlen=RECENT_TASKS))
|
|
255
|
+
stopping: bool = False
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def elapsed(self) -> float:
|
|
259
|
+
return time.monotonic() - self.started
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def eta(self) -> float | None:
|
|
263
|
+
if not self.overall_done or self.overall_done >= self.overall_total:
|
|
264
|
+
return None
|
|
265
|
+
rate = self.elapsed / self.overall_done
|
|
266
|
+
return rate * (self.overall_total - self.overall_done)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class _Dashboard:
|
|
270
|
+
"""Renderable redrawn by `rich.live.Live` on every refresh."""
|
|
271
|
+
|
|
272
|
+
def __init__(self, state: _RunState, glyphs: _Glyphs) -> None:
|
|
273
|
+
self.state = state
|
|
274
|
+
self.glyphs = glyphs
|
|
275
|
+
|
|
276
|
+
def __rich_console__(
|
|
277
|
+
self, console: Console, options: ConsoleOptions
|
|
278
|
+
) -> RenderResult:
|
|
279
|
+
width = max(40, options.max_width)
|
|
280
|
+
state = self.state
|
|
281
|
+
view = state.current
|
|
282
|
+
|
|
283
|
+
yield Text()
|
|
284
|
+
if view is not None:
|
|
285
|
+
yield from self._job_lines(view, width)
|
|
286
|
+
yield from state.recent
|
|
287
|
+
if state.recent:
|
|
288
|
+
yield Text()
|
|
289
|
+
yield self._overall_line(width)
|
|
290
|
+
|
|
291
|
+
def _job_lines(self, view: _JobView, width: int) -> RenderResult:
|
|
292
|
+
glyphs = self.glyphs
|
|
293
|
+
title = Text()
|
|
294
|
+
title.append(view.job.benchmark_label, style="bold")
|
|
295
|
+
title.append(f" {glyphs.dot} ", style="dim")
|
|
296
|
+
title.append(view.job.model)
|
|
297
|
+
title.append(f" {glyphs.dot} {view.job.harness_label}", style="cyan")
|
|
298
|
+
meta = Text(
|
|
299
|
+
f"job {view.index + 1}/{len(self.state.jobs)} {glyphs.dot} "
|
|
300
|
+
f"{slice_label(view.job.slice_spec)} {glyphs.dot} "
|
|
301
|
+
f"{view.concurrency} concurrent",
|
|
302
|
+
style="dim",
|
|
303
|
+
)
|
|
304
|
+
yield _spread(title, meta, width)
|
|
305
|
+
|
|
306
|
+
counters = _counters(view.stats, live_looping=view.live_looping)
|
|
307
|
+
progress = Text(f"{view.completed}/{view.total}".rjust(9), style="dim")
|
|
308
|
+
score = _score_text(view.stats.score)
|
|
309
|
+
tail = Text.assemble(progress, " ", score, " ", counters)
|
|
310
|
+
bar_width = max(8, width - tail.cell_len - 2)
|
|
311
|
+
yield Text.assemble(
|
|
312
|
+
_bar(view.completed / max(view.total, 1), bar_width, glyphs), " ", tail
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
activity = Text()
|
|
316
|
+
activity.append(glyphs.frame(), style="cyan")
|
|
317
|
+
activity.append(" ")
|
|
318
|
+
activity.append_text(view.activity)
|
|
319
|
+
activity.truncate(width, overflow="ellipsis")
|
|
320
|
+
yield activity
|
|
321
|
+
yield Text()
|
|
322
|
+
|
|
323
|
+
def _overall_line(self, width: int) -> Text:
|
|
324
|
+
state = self.state
|
|
325
|
+
glyphs = self.glyphs
|
|
326
|
+
percent = state.overall_done / max(state.overall_total, 1)
|
|
327
|
+
parts = [f"{state.overall_done}/{state.overall_total} tasks"]
|
|
328
|
+
parts.append(f"{percent * 100:.0f}%")
|
|
329
|
+
parts.append(_fmt_time(state.elapsed))
|
|
330
|
+
eta = state.eta
|
|
331
|
+
if eta is not None and not state.stopping:
|
|
332
|
+
parts.append(f"~{_fmt_time(eta)} left")
|
|
333
|
+
if state.stopping:
|
|
334
|
+
parts.append("stopping")
|
|
335
|
+
label = Text("overall ", style="dim")
|
|
336
|
+
tail = Text(f" {f' {glyphs.dot} '.join(parts)}", style="dim")
|
|
337
|
+
bar_width = max(8, width - label.cell_len - tail.cell_len)
|
|
338
|
+
return Text.assemble(
|
|
339
|
+
label,
|
|
340
|
+
_bar(
|
|
341
|
+
percent,
|
|
342
|
+
bar_width,
|
|
343
|
+
glyphs,
|
|
344
|
+
style="bright_black",
|
|
345
|
+
empty_style="bright_black",
|
|
346
|
+
),
|
|
347
|
+
tail,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class _Reporter:
|
|
352
|
+
"""Turns engine events into either a live dashboard or plain log lines."""
|
|
353
|
+
|
|
354
|
+
def __init__(self, console: Console, *, verbose: bool) -> None:
|
|
355
|
+
self.console = console
|
|
356
|
+
self.verbose = verbose
|
|
357
|
+
self.glyphs = _Glyphs.for_console(console)
|
|
358
|
+
self.plain = not console.is_terminal
|
|
359
|
+
self.state = _RunState()
|
|
360
|
+
self._live: Live | None = None
|
|
361
|
+
|
|
362
|
+
def __enter__(self) -> _Reporter:
|
|
363
|
+
if not self.plain:
|
|
364
|
+
self._live = Live(
|
|
365
|
+
_Dashboard(self.state, self.glyphs),
|
|
366
|
+
console=self.console,
|
|
367
|
+
refresh_per_second=10,
|
|
368
|
+
transient=True,
|
|
369
|
+
)
|
|
370
|
+
self._live.__enter__()
|
|
371
|
+
return self
|
|
372
|
+
|
|
373
|
+
def __exit__(self, *exc: object) -> None:
|
|
374
|
+
if self._live is not None:
|
|
375
|
+
self._live.__exit__(*exc)
|
|
376
|
+
self._live = None
|
|
377
|
+
|
|
378
|
+
def log(self, renderable: object) -> None:
|
|
379
|
+
"""Print above the dashboard, where it stays in scrollback."""
|
|
380
|
+
# Log lines are read by machines as often as by people, so a piped run
|
|
381
|
+
# keeps one event on one line instead of wrapping at 80 columns.
|
|
382
|
+
self.console.print(renderable, soft_wrap=self.plain)
|
|
383
|
+
|
|
384
|
+
def _stamp(self) -> str:
|
|
385
|
+
return time.strftime("%H:%M:%S")
|
|
386
|
+
|
|
387
|
+
# -- events ---------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
def __call__(self, event: object) -> None:
|
|
390
|
+
if isinstance(event, RunStarted):
|
|
391
|
+
self.on_run_started(event)
|
|
392
|
+
elif isinstance(event, JobStarted):
|
|
393
|
+
self.on_job_started(event)
|
|
394
|
+
elif isinstance(event, TaskPhase):
|
|
395
|
+
self.on_task_phase(event)
|
|
396
|
+
elif isinstance(event, GenerationProgress):
|
|
397
|
+
self.on_generation(event)
|
|
398
|
+
elif isinstance(event, TaskCompleted):
|
|
399
|
+
self.on_task_completed(event)
|
|
400
|
+
elif isinstance(event, JobCompleted):
|
|
401
|
+
self.on_job_completed(event)
|
|
402
|
+
elif isinstance(event, RunFailed):
|
|
403
|
+
self.log(Text.assemble(("Run failed: ", "bold red"), event.message))
|
|
404
|
+
elif isinstance(event, ModelUnloaded):
|
|
405
|
+
self.on_model_unloaded(event)
|
|
406
|
+
elif isinstance(event, RunCompleted):
|
|
407
|
+
self.on_run_completed(event)
|
|
408
|
+
|
|
409
|
+
def on_run_started(self, event: RunStarted) -> None:
|
|
410
|
+
self.state.jobs = list(event.jobs)
|
|
411
|
+
self.state.overall_total = event.total_tasks
|
|
412
|
+
self.state.started = time.monotonic()
|
|
413
|
+
models = len({job.model for job in event.jobs})
|
|
414
|
+
benchmarks = len({job.benchmark for job in event.jobs})
|
|
415
|
+
self.log(
|
|
416
|
+
Text.assemble(
|
|
417
|
+
(_plural(len(event.jobs), "job"), "bold"),
|
|
418
|
+
(
|
|
419
|
+
f" {self.glyphs.dot} {_plural(models, 'model')}"
|
|
420
|
+
f" {self.glyphs.dot} {_plural(benchmarks, 'benchmark')}"
|
|
421
|
+
f" {self.glyphs.dot} {_plural(event.total_tasks, 'task')}",
|
|
422
|
+
"dim",
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def on_job_started(self, event: JobStarted) -> None:
|
|
428
|
+
self.state.current = _JobView(
|
|
429
|
+
index=event.index,
|
|
430
|
+
job=event.job,
|
|
431
|
+
total=max(event.total, 1),
|
|
432
|
+
concurrency=event.concurrency,
|
|
433
|
+
)
|
|
434
|
+
self.state.recent.clear()
|
|
435
|
+
if self.plain:
|
|
436
|
+
self.log(
|
|
437
|
+
Text(
|
|
438
|
+
f"[{self._stamp()}] job {event.index + 1}/{len(self.state.jobs)}"
|
|
439
|
+
f" {event.job.benchmark_label} on {event.job.model}"
|
|
440
|
+
f" via {event.job.harness_label}"
|
|
441
|
+
f" {self.glyphs.dot} {event.total} tasks"
|
|
442
|
+
f" {self.glyphs.dot} {event.concurrency} concurrent"
|
|
443
|
+
f" {self.glyphs.dot} {slice_label(event.job.slice_spec)}",
|
|
444
|
+
style="dim",
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
def _activity(self, label: str, detail: Text) -> None:
|
|
449
|
+
view = self.state.current
|
|
450
|
+
if view is None:
|
|
451
|
+
return
|
|
452
|
+
text = Text()
|
|
453
|
+
text.append(label, style="bold")
|
|
454
|
+
text.append(f" {self.glyphs.dot} ", style="dim")
|
|
455
|
+
text.append_text(detail)
|
|
456
|
+
view.activity = text
|
|
457
|
+
|
|
458
|
+
def _concurrent_activity(self, view: _JobView) -> None:
|
|
459
|
+
"""Show one stable aggregate instead of whichever stream updated last."""
|
|
460
|
+
detail = Text(
|
|
461
|
+
f"{_plural(len(view.active_positions), 'active request')}"
|
|
462
|
+
f" {self.glyphs.dot} {view.generated_chars:,} chars generated",
|
|
463
|
+
style="dim",
|
|
464
|
+
)
|
|
465
|
+
if view.looping_positions:
|
|
466
|
+
detail.append(f" {self.glyphs.dot} ", style="dim")
|
|
467
|
+
detail.append(
|
|
468
|
+
_plural(len(view.looping_positions), "looping request"),
|
|
469
|
+
style="bold magenta",
|
|
470
|
+
)
|
|
471
|
+
self._activity("Concurrent generation", detail)
|
|
472
|
+
|
|
473
|
+
def on_task_phase(self, event: TaskPhase) -> None:
|
|
474
|
+
view = self.state.current
|
|
475
|
+
if view is None:
|
|
476
|
+
return
|
|
477
|
+
if event.phase == "generating":
|
|
478
|
+
view.looping_positions.discard(event.position)
|
|
479
|
+
view.active_positions.add(event.position)
|
|
480
|
+
else:
|
|
481
|
+
view.active_positions.discard(event.position)
|
|
482
|
+
view.live_looping = bool(view.looping_positions)
|
|
483
|
+
if view.concurrency > 1:
|
|
484
|
+
self._concurrent_activity(view)
|
|
485
|
+
return
|
|
486
|
+
style = "yellow" if event.phase in {"timed_out", "loop_killed"} else "dim"
|
|
487
|
+
self._activity(event.label, Text(event.activity, style=style))
|
|
488
|
+
|
|
489
|
+
def on_generation(self, event: GenerationProgress) -> None:
|
|
490
|
+
view = self.state.current
|
|
491
|
+
if view is None:
|
|
492
|
+
return
|
|
493
|
+
view.active_positions.add(event.position)
|
|
494
|
+
view.progress_by_position[event.position] = event
|
|
495
|
+
if event.loop_state == "looping":
|
|
496
|
+
view.looping_positions.add(event.position)
|
|
497
|
+
else:
|
|
498
|
+
view.looping_positions.discard(event.position)
|
|
499
|
+
view.live_looping = bool(view.looping_positions)
|
|
500
|
+
if view.concurrency > 1:
|
|
501
|
+
self._concurrent_activity(view)
|
|
502
|
+
return
|
|
503
|
+
detail = Text(event.phase, style="dim")
|
|
504
|
+
detail.append(
|
|
505
|
+
f" {self.glyphs.dot} "
|
|
506
|
+
f"{event.thinking_chars + event.response_chars:,} chars"
|
|
507
|
+
f" {self.glyphs.dot} {event.elapsed_s:.0f}s",
|
|
508
|
+
style="dim",
|
|
509
|
+
)
|
|
510
|
+
if event.loop_state == "looping":
|
|
511
|
+
detail.append(f" {self.glyphs.dot} ", style="dim")
|
|
512
|
+
detail.append("LOOPING", style="bold magenta")
|
|
513
|
+
elif event.loop_state == "suspected":
|
|
514
|
+
detail.append(f" {self.glyphs.dot} loop suspected", style="magenta")
|
|
515
|
+
if event.loop_kill_remaining_s is not None:
|
|
516
|
+
detail.append(
|
|
517
|
+
f" {self.glyphs.dot} kill in {event.loop_kill_remaining_s:.1f}s",
|
|
518
|
+
style="bold red",
|
|
519
|
+
)
|
|
520
|
+
self._activity(event.label, detail)
|
|
521
|
+
|
|
522
|
+
def _task_line(self, record: TaskRecord, position: int, total: int) -> Text:
|
|
523
|
+
glyphs = self.glyphs
|
|
524
|
+
if record.passed:
|
|
525
|
+
mark, style, word = glyphs.passed, "green", "PASS"
|
|
526
|
+
elif record.score > 0:
|
|
527
|
+
mark, style, word = glyphs.failed, "yellow", f"PARTIAL {record.score:.0%}"
|
|
528
|
+
elif record.loop_killed:
|
|
529
|
+
mark, style, word = glyphs.error, "magenta", "LOOP KILLED"
|
|
530
|
+
elif record.timed_out:
|
|
531
|
+
mark, style, word = glyphs.error, "yellow", "TIMEOUT"
|
|
532
|
+
elif record.length_exceeded:
|
|
533
|
+
mark, style, word = glyphs.error, "yellow", "LENGTH LIMIT"
|
|
534
|
+
elif record.error:
|
|
535
|
+
mark, style, word = glyphs.error, "yellow", "ERROR"
|
|
536
|
+
else:
|
|
537
|
+
mark, style, word = glyphs.failed, "red", "FAIL"
|
|
538
|
+
|
|
539
|
+
line = Text(" ")
|
|
540
|
+
line.append(mark, style=style)
|
|
541
|
+
line.append(" ")
|
|
542
|
+
line.append(f"{position}/{total}".rjust(len(str(total)) * 2 + 1), style="dim")
|
|
543
|
+
line.append(" ")
|
|
544
|
+
line.append(word.ljust(11), style=f"bold {style}")
|
|
545
|
+
line.append(record.label)
|
|
546
|
+
if record.decision:
|
|
547
|
+
line.append(f" {record.decision['latency_ms']:.0f} ms", style="dim")
|
|
548
|
+
else:
|
|
549
|
+
line.append(
|
|
550
|
+
f" {record.response_time_s:.1f}s {glyphs.dot} "
|
|
551
|
+
f"{record.tok_s:.0f} stream tok/s",
|
|
552
|
+
style="dim",
|
|
553
|
+
)
|
|
554
|
+
if record.recovered_cycle:
|
|
555
|
+
line.append(f" {glyphs.dot} loop recovered", style="yellow")
|
|
556
|
+
elif record.loop_state == "looping" and not record.loop_killed:
|
|
557
|
+
line.append(f" {glyphs.dot} looped", style="magenta")
|
|
558
|
+
if record.error:
|
|
559
|
+
line.append(f" {record.error}", style="dim")
|
|
560
|
+
return line
|
|
561
|
+
|
|
562
|
+
def on_task_completed(self, event: TaskCompleted) -> None:
|
|
563
|
+
view = self.state.current
|
|
564
|
+
record = event.record
|
|
565
|
+
self.state.overall_done += 1
|
|
566
|
+
if view is not None:
|
|
567
|
+
view.stats.add(record)
|
|
568
|
+
view.completed = event.completed
|
|
569
|
+
view.looping_positions.discard(record.index + 1)
|
|
570
|
+
view.active_positions.discard(record.index + 1)
|
|
571
|
+
last_progress = view.progress_by_position.pop(record.index + 1, None)
|
|
572
|
+
streamed_chars = (
|
|
573
|
+
last_progress.thinking_chars + last_progress.response_chars
|
|
574
|
+
if last_progress is not None
|
|
575
|
+
else 0
|
|
576
|
+
)
|
|
577
|
+
view.completed_chars += max(
|
|
578
|
+
streamed_chars,
|
|
579
|
+
len(record.thinking) + len(record.response),
|
|
580
|
+
)
|
|
581
|
+
view.live_looping = bool(view.looping_positions)
|
|
582
|
+
if view.concurrency > 1:
|
|
583
|
+
self._concurrent_activity(view)
|
|
584
|
+
total = view.total if view is not None else event.completed
|
|
585
|
+
line = self._task_line(record, record.index + 1, total)
|
|
586
|
+
if self.plain:
|
|
587
|
+
stamped = Text()
|
|
588
|
+
stamped.append(f"[{self._stamp()}]", style="dim")
|
|
589
|
+
self.log(stamped.append_text(line))
|
|
590
|
+
else:
|
|
591
|
+
self.state.recent.append(line)
|
|
592
|
+
# Anything that is not an ordinary miss is worth keeping in
|
|
593
|
+
# scrollback, since the dashboard window scrolls it away.
|
|
594
|
+
if (
|
|
595
|
+
record.error
|
|
596
|
+
or record.timed_out
|
|
597
|
+
or record.loop_killed
|
|
598
|
+
or record.length_exceeded
|
|
599
|
+
):
|
|
600
|
+
self.log(line)
|
|
601
|
+
if self.verbose:
|
|
602
|
+
self._verbose_task(record)
|
|
603
|
+
|
|
604
|
+
def _verbose_task(self, record: TaskRecord) -> None:
|
|
605
|
+
console = self.console
|
|
606
|
+
console.print()
|
|
607
|
+
console.print(Text(record.label, style="bold"))
|
|
608
|
+
console.print(Text("Prompt", style="dim"))
|
|
609
|
+
console.print(record.prompt, markup=False, highlight=False)
|
|
610
|
+
if record.thinking:
|
|
611
|
+
console.print(Text("Thinking", style="dim"))
|
|
612
|
+
console.print(record.thinking, markup=False, highlight=False)
|
|
613
|
+
console.print(Text("Response", style="dim"))
|
|
614
|
+
console.print(record.response, markup=False, highlight=False)
|
|
615
|
+
console.print()
|
|
616
|
+
|
|
617
|
+
def on_job_completed(self, event: JobCompleted) -> None:
|
|
618
|
+
result = event.result
|
|
619
|
+
view = self.state.current
|
|
620
|
+
self.state.current = None
|
|
621
|
+
glyphs = self.glyphs
|
|
622
|
+
stamp = f"[{self._stamp()}] " if self.plain else ""
|
|
623
|
+
|
|
624
|
+
if not result["total"]:
|
|
625
|
+
self.log(
|
|
626
|
+
Text.assemble(
|
|
627
|
+
(f"{stamp}{glyphs.error} ", "yellow"),
|
|
628
|
+
("skipped".ljust(8), "bold yellow"),
|
|
629
|
+
(f"{result['benchmark']} on {result['model']}", "dim"),
|
|
630
|
+
)
|
|
631
|
+
)
|
|
632
|
+
return
|
|
633
|
+
|
|
634
|
+
stats = view.stats if view is not None else _LiveStats()
|
|
635
|
+
partial = self.state.stopping or result["total"] < (
|
|
636
|
+
view.total if view is not None else result["total"]
|
|
637
|
+
)
|
|
638
|
+
line = Text()
|
|
639
|
+
line.append(
|
|
640
|
+
f"{stamp}{glyphs.error if partial else glyphs.passed} ", style="dim"
|
|
641
|
+
)
|
|
642
|
+
line.append(result.get("benchmark_label", result["benchmark"]), style="bold")
|
|
643
|
+
line.append(f" {glyphs.dot} ", style="dim")
|
|
644
|
+
line.append(result["model"])
|
|
645
|
+
line.append(
|
|
646
|
+
f" {glyphs.dot} {result.get('harness_label', 'Direct')}", style="cyan"
|
|
647
|
+
)
|
|
648
|
+
line.append(" ")
|
|
649
|
+
line.append_text(_score_text(result["score"]))
|
|
650
|
+
line.append(" ")
|
|
651
|
+
line.append_text(_counters(stats))
|
|
652
|
+
if is_decision(result):
|
|
653
|
+
speed, latency = decision_speed(result)
|
|
654
|
+
throughput = f" {glyphs.dot} {speed} {glyphs.dot} {latency}"
|
|
655
|
+
elif result.get("concurrency", 1) > 1:
|
|
656
|
+
throughput = (
|
|
657
|
+
f" {glyphs.dot} {aggregate_tok_s(result):.1f} aggregate tok/s"
|
|
658
|
+
f" {glyphs.dot} {stream_tok_s(result):.1f} stream tok/s"
|
|
659
|
+
f" {glyphs.dot} {effective_concurrency(result):.2f}x effective"
|
|
660
|
+
)
|
|
661
|
+
else:
|
|
662
|
+
throughput = f" {glyphs.dot} {stream_tok_s(result):.1f} tok/s"
|
|
663
|
+
line.append(
|
|
664
|
+
f" {_plural(result['total'], 'task')}"
|
|
665
|
+
f"{' (partial)' if partial else ''}"
|
|
666
|
+
f"{throughput}"
|
|
667
|
+
f" {glyphs.dot} {result.get('loop_kills', 0)} killed"
|
|
668
|
+
f" {glyphs.dot} {_fmt_time(result['total_time'])}",
|
|
669
|
+
style="dim",
|
|
670
|
+
)
|
|
671
|
+
self.log(line)
|
|
672
|
+
|
|
673
|
+
def on_model_unloaded(self, event: ModelUnloaded) -> None:
|
|
674
|
+
stamp = f"[{self._stamp()}] " if self.plain else ""
|
|
675
|
+
if event.error:
|
|
676
|
+
self.log(
|
|
677
|
+
Text.assemble(
|
|
678
|
+
(f"{stamp}force-unload failed for ", "bold red"),
|
|
679
|
+
(event.model, "bold"),
|
|
680
|
+
(f" — {event.error}", "red"),
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
else:
|
|
684
|
+
self.log(
|
|
685
|
+
Text.assemble(
|
|
686
|
+
(stamp, "dim"),
|
|
687
|
+
("force-unloading ", "dim"),
|
|
688
|
+
(event.model, "bold"),
|
|
689
|
+
(" before the next model", "dim"),
|
|
690
|
+
)
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
def on_run_completed(self, event: RunCompleted) -> None:
|
|
694
|
+
if not event.results:
|
|
695
|
+
return
|
|
696
|
+
glyphs = self.glyphs
|
|
697
|
+
word = "Stopped" if event.stopped else "Finished"
|
|
698
|
+
self.log(
|
|
699
|
+
Text(
|
|
700
|
+
f"{word} {_plural(len(event.results), 'job')}"
|
|
701
|
+
f" {glyphs.dot} {_plural(self.state.overall_done, 'task')}"
|
|
702
|
+
f" {glyphs.dot} {_fmt_time(event.elapsed)}",
|
|
703
|
+
style="dim",
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def run(
|
|
709
|
+
client: InferenceClient,
|
|
710
|
+
jobs: list[JobSpec],
|
|
711
|
+
console: Console,
|
|
712
|
+
verbose: bool = False,
|
|
713
|
+
force_unload: bool = False,
|
|
714
|
+
) -> tuple[list[dict], str | None]:
|
|
715
|
+
"""Run every job, printing progress to `console`.
|
|
716
|
+
|
|
717
|
+
Returns the results plus the failure message when the run ended early.
|
|
718
|
+
Ctrl+C cancels every active request, so a partial run still reports and
|
|
719
|
+
still writes its artifacts; a second Ctrl+C aborts.
|
|
720
|
+
"""
|
|
721
|
+
controls = RunControls(force_unload=force_unload)
|
|
722
|
+
reporter = _Reporter(console, verbose=verbose)
|
|
723
|
+
if force_unload:
|
|
724
|
+
reporter.log(
|
|
725
|
+
Text(
|
|
726
|
+
"Force-unload enabled: each model is evicted from VRAM "
|
|
727
|
+
"before the next model loads",
|
|
728
|
+
style="dim",
|
|
729
|
+
)
|
|
730
|
+
)
|
|
731
|
+
engine = Engine(client=client, jobs=jobs, sink=reporter, controls=controls)
|
|
732
|
+
|
|
733
|
+
def on_interrupt(signum: int, frame: FrameType | None) -> None:
|
|
734
|
+
if controls.stopped:
|
|
735
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
736
|
+
raise KeyboardInterrupt
|
|
737
|
+
reporter.state.stopping = True
|
|
738
|
+
controls.stop()
|
|
739
|
+
reporter.log(
|
|
740
|
+
Text("Interrupted, cancelling active requests…", style="bold yellow")
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
def on_terminate(signum: int, frame: FrameType | None) -> None:
|
|
744
|
+
# SIGHUP (for example, a dropped SSH session) and SIGTERM do not offer
|
|
745
|
+
# an interactive second signal. Unwind immediately so Engine.finally
|
|
746
|
+
# can remove its containers, networks, images, builders, and volumes.
|
|
747
|
+
controls.stop()
|
|
748
|
+
signal.signal(signum, signal.SIG_IGN)
|
|
749
|
+
raise SystemExit(128 + signum)
|
|
750
|
+
|
|
751
|
+
handlers = [(signal.SIGINT, on_interrupt), (signal.SIGTERM, on_terminate)]
|
|
752
|
+
if hasattr(signal, "SIGHUP"):
|
|
753
|
+
handlers.append((signal.SIGHUP, on_terminate))
|
|
754
|
+
previous: dict[int, signal.Handlers] = {}
|
|
755
|
+
for signum, handler in handlers:
|
|
756
|
+
with contextlib.suppress(ValueError): # not on the main thread
|
|
757
|
+
previous[signum] = signal.signal(signum, handler)
|
|
758
|
+
|
|
759
|
+
try:
|
|
760
|
+
with reporter:
|
|
761
|
+
results = engine.run()
|
|
762
|
+
except KeyboardInterrupt:
|
|
763
|
+
console.print(Text("Aborted.", style="bold yellow"))
|
|
764
|
+
return [], "interrupted"
|
|
765
|
+
finally:
|
|
766
|
+
for signum, handler in previous.items():
|
|
767
|
+
signal.signal(signum, handler)
|
|
768
|
+
|
|
769
|
+
return results, engine.failure
|