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.
Files changed (149) hide show
  1. benchkit/__init__.py +1 -0
  2. benchkit/_pi_proxy.py +219 -0
  3. benchkit/answer_key_guard.ts +49 -0
  4. benchkit/artifacts.py +87 -0
  5. benchkit/benchmarks/__init__.py +105 -0
  6. benchkit/benchmarks/aider_polyglot.py +405 -0
  7. benchkit/benchmarks/arc.py +52 -0
  8. benchkit/benchmarks/banking77.py +149 -0
  9. benchkit/benchmarks/base.py +19 -0
  10. benchkit/benchmarks/boolq.py +49 -0
  11. benchkit/benchmarks/chartqa.py +133 -0
  12. benchkit/benchmarks/code_verifier.py +77 -0
  13. benchkit/benchmarks/downloads.py +166 -0
  14. benchkit/benchmarks/evalplus.py +288 -0
  15. benchkit/benchmarks/git_surgery.py +485 -0
  16. benchkit/benchmarks/gpqa.py +56 -0
  17. benchkit/benchmarks/gsm8k.py +97 -0
  18. benchkit/benchmarks/hellaswag.py +49 -0
  19. benchkit/benchmarks/humaneval.py +103 -0
  20. benchkit/benchmarks/ifeval.py +56 -0
  21. benchkit/benchmarks/ifeval_instructions.py +477 -0
  22. benchkit/benchmarks/mbpp.py +60 -0
  23. benchkit/benchmarks/mc_arena.py +479 -0
  24. benchkit/benchmarks/mcq.py +94 -0
  25. benchkit/benchmarks/medfailbench.py +537 -0
  26. benchkit/benchmarks/mmlu.py +54 -0
  27. benchkit/benchmarks/mmlu_pro.py +62 -0
  28. benchkit/benchmarks/mmvp.py +115 -0
  29. benchkit/benchmarks/ocrbench.py +96 -0
  30. benchkit/benchmarks/openbookqa.py +52 -0
  31. benchkit/benchmarks/patcheval.py +929 -0
  32. benchkit/benchmarks/piqa.py +52 -0
  33. benchkit/benchmarks/ruler.py +172 -0
  34. benchkit/benchmarks/ruler_generator.py +535 -0
  35. benchkit/benchmarks/sanity.py +174 -0
  36. benchkit/benchmarks/treejs_arena.py +268 -0
  37. benchkit/benchmarks/truthfulqa.py +52 -0
  38. benchkit/benchmarks/utils.py +25 -0
  39. benchkit/benchmarks/vision.py +230 -0
  40. benchkit/benchmarks/vstar.py +108 -0
  41. benchkit/benchmarks/winogrande.py +52 -0
  42. benchkit/benchmarks/xstest.py +116 -0
  43. benchkit/browser.py +429 -0
  44. benchkit/catalog.py +121 -0
  45. benchkit/cli.py +1000 -0
  46. benchkit/client.py +1413 -0
  47. benchkit/controls.py +87 -0
  48. benchkit/datasets/MMLU_LICENSE.txt +21 -0
  49. benchkit/datasets/README.md +142 -0
  50. benchkit/datasets/arc_challenge.jsonl +1172 -0
  51. benchkit/datasets/banking77.jsonl +3080 -0
  52. benchkit/datasets/banking77_intents.json +79 -0
  53. benchkit/datasets/boolq.jsonl +3270 -0
  54. benchkit/datasets/code_verifier.jsonl +500 -0
  55. benchkit/datasets/gpqa_diamond.jsonl +198 -0
  56. benchkit/datasets/gsm8k.jsonl +1319 -0
  57. benchkit/datasets/hellaswag.jsonl +1000 -0
  58. benchkit/datasets/humaneval.jsonl +164 -0
  59. benchkit/datasets/ifeval.jsonl +541 -0
  60. benchkit/datasets/mbpp.jsonl +500 -0
  61. benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
  62. benchkit/datasets/medfailbench_hard30.jsonl +30 -0
  63. benchkit/datasets/medfailbench_hard30.md +13 -0
  64. benchkit/datasets/openbookqa.jsonl +500 -0
  65. benchkit/datasets/piqa.jsonl +1838 -0
  66. benchkit/datasets/sanity_code.jsonl +6 -0
  67. benchkit/datasets/truthfulqa.jsonl +817 -0
  68. benchkit/datasets/winogrande.jsonl +1267 -0
  69. benchkit/datasets/xstest.csv +451 -0
  70. benchkit/decision.py +194 -0
  71. benchkit/demo.py +451 -0
  72. benchkit/dockerfiles.py +247 -0
  73. benchkit/engine.py +1270 -0
  74. benchkit/evaluation.py +195 -0
  75. benchkit/events.py +141 -0
  76. benchkit/executor.py +96 -0
  77. benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
  78. benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
  79. benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
  80. benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
  81. benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
  82. benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
  83. benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
  84. benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
  85. benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
  86. benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
  87. benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
  88. benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
  89. benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
  90. benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
  91. benchkit/git_surgery/secret-in-history/README.md +27 -0
  92. benchkit/git_surgery/secret-in-history/setup.sh +107 -0
  93. benchkit/git_surgery/secret-in-history/verify.sh +106 -0
  94. benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
  95. benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
  96. benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
  97. benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
  98. benchkit/history.py +474 -0
  99. benchkit/jobs.py +332 -0
  100. benchkit/leakage.py +51 -0
  101. benchkit/looping.py +873 -0
  102. benchkit/mc_render.py +274 -0
  103. benchkit/mc_viewer/dist/atlas.png +0 -0
  104. benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
  105. benchkit/mc_viewer/dist/version.json +4 -0
  106. benchkit/mc_viewer/dist/viewer.html +23 -0
  107. benchkit/mc_viewer/dist/viewer.js +2 -0
  108. benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
  109. benchkit/mc_viewer/dist/worker.js +2 -0
  110. benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
  111. benchkit/metrics.py +100 -0
  112. benchkit/ollama.py +60 -0
  113. benchkit/perf.py +353 -0
  114. benchkit/perf_report.py +207 -0
  115. benchkit/perturbations.py +208 -0
  116. benchkit/pi_agent.py +476 -0
  117. benchkit/pi_package/package-lock.json +1827 -0
  118. benchkit/pi_package/package.json +8 -0
  119. benchkit/report.py +142 -0
  120. benchkit/report_markdown.py +645 -0
  121. benchkit/results.py +688 -0
  122. benchkit/retry.py +163 -0
  123. benchkit/runner.py +769 -0
  124. benchkit/sandbox.py +1167 -0
  125. benchkit/template_check.py +291 -0
  126. benchkit/templates/arena.html +504 -0
  127. benchkit/templates/history.html +58 -0
  128. benchkit/templates/perf.html +509 -0
  129. benchkit/templates/report.html +769 -0
  130. benchkit/tui/__init__.py +5 -0
  131. benchkit/tui/app.py +87 -0
  132. benchkit/tui/app.tcss +512 -0
  133. benchkit/tui/formatting.py +84 -0
  134. benchkit/tui/screens/__init__.py +1 -0
  135. benchkit/tui/screens/connect.py +191 -0
  136. benchkit/tui/screens/detail.py +288 -0
  137. benchkit/tui/screens/help.py +84 -0
  138. benchkit/tui/screens/modals.py +544 -0
  139. benchkit/tui/screens/results.py +361 -0
  140. benchkit/tui/screens/run.py +988 -0
  141. benchkit/tui/screens/setup.py +733 -0
  142. benchkit/tui/theme.py +135 -0
  143. benchkit/tui/widgets.py +120 -0
  144. benchkit_cli-0.1.0.dist-info/METADATA +294 -0
  145. benchkit_cli-0.1.0.dist-info/RECORD +149 -0
  146. benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
  147. benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
  148. benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  149. benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,544 @@
1
+ """Modal dialogs: task limits, task inspection, datasets and confirmations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+ from textual import work
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.content import Content
11
+ from textual.css.query import NoMatches
12
+ from textual.screen import ModalScreen
13
+ from textual.widgets import (
14
+ Button,
15
+ DataTable,
16
+ Footer,
17
+ Input,
18
+ Static,
19
+ TabbedContent,
20
+ TabPane,
21
+ TextArea,
22
+ )
23
+
24
+ from benchkit import catalog
25
+ from benchkit.benchmarks.downloads import fmt_bytes
26
+ from benchkit.engine import SliceError, parse_slice, slice_label
27
+ from benchkit.template_check import TemplateReport
28
+ from benchkit.tui.formatting import fmt_count, fmt_duration
29
+
30
+
31
+ class LimitScreen(ModalScreen[str | None]):
32
+ """Ask for a per-benchmark task slice."""
33
+
34
+ BINDINGS = [
35
+ Binding("escape", "cancel", "Cancel"),
36
+ Binding("ctrl+x", "clear", "Run all tasks"),
37
+ ]
38
+
39
+ def __init__(self, benchmark: str, current: str, total: int) -> None:
40
+ super().__init__()
41
+ self.benchmark = benchmark
42
+ self.current = current
43
+ self.total = total
44
+
45
+ def compose(self) -> ComposeResult:
46
+ with Vertical(id="limit-dialog", classes="dialog"):
47
+ yield Static(f"Task limit · {self.benchmark}", classes="card-title")
48
+ yield Static(f"{fmt_count(self.total)} tasks available", classes="hint")
49
+ yield Input(value=self.current, placeholder="all tasks", id="limit-input")
50
+ yield Static(
51
+ "20 → first 20 -20 → last 20 40-80 → tasks 40 through 80",
52
+ classes="hint",
53
+ )
54
+ yield Static("", id="limit-preview", classes="hint")
55
+ with Horizontal(classes="dialog-actions"):
56
+ yield Button("Apply", id="apply", variant="primary")
57
+ yield Button("Run all", id="clear")
58
+ yield Button("Cancel", id="cancel")
59
+
60
+ def on_mount(self) -> None:
61
+ self.query_one("#limit-input", Input).focus()
62
+ self._preview(self.current)
63
+
64
+ def on_input_changed(self, event: Input.Changed) -> None:
65
+ self._preview(event.value)
66
+
67
+ def on_input_submitted(self) -> None:
68
+ self._apply()
69
+
70
+ def on_button_pressed(self, event: Button.Pressed) -> None:
71
+ if event.button.id == "apply":
72
+ self._apply()
73
+ elif event.button.id == "clear":
74
+ self.action_clear()
75
+ else:
76
+ self.action_cancel()
77
+
78
+ def _preview(self, value: str) -> None:
79
+ preview = self.query_one("#limit-preview", Static)
80
+ spec = value.strip()
81
+ if not spec:
82
+ preview.update("Runs every task.")
83
+ return
84
+ try:
85
+ start, end = parse_slice(spec, self.total or 1)
86
+ except SliceError:
87
+ preview.update("[b]Invalid[/b] — use N, -N or A-B.")
88
+ return
89
+ preview.update(f"{slice_label(spec)} → {fmt_count(max(0, end - start))} tasks")
90
+
91
+ def _apply(self) -> None:
92
+ spec = self.query_one("#limit-input", Input).value.strip()
93
+ if spec:
94
+ try:
95
+ parse_slice(spec, self.total or 1)
96
+ except SliceError:
97
+ self.notify("Invalid slice — use N, -N or A-B.", severity="error")
98
+ return
99
+ self.dismiss(spec or None)
100
+
101
+ def action_clear(self) -> None:
102
+ self.dismiss(None)
103
+
104
+ def action_cancel(self) -> None:
105
+ self.dismiss(self.current or None)
106
+
107
+
108
+ def _attempts_text(attempts: list[dict]) -> str:
109
+ """Render verifier-feedback attempts as a readable transcript."""
110
+ sections = []
111
+ for attempt in attempts:
112
+ number = int(attempt.get("attempt") or len(sections) + 1)
113
+ status = "PASS" if attempt.get("passed") else "FAIL"
114
+ lines = [
115
+ f"ATTEMPT {number} · {status} · {float(attempt.get('score') or 0):.1f}%"
116
+ ]
117
+ if attempt.get("feedback"):
118
+ lines.extend(["", "Verifier feedback:", str(attempt["feedback"])])
119
+ if attempt.get("thinking"):
120
+ lines.extend(["", "Thinking:", str(attempt["thinking"])])
121
+ lines.extend(["", "Response:", str(attempt.get("response") or "")])
122
+ sections.append("\n".join(lines))
123
+ return ("\n\n" + "─" * 48 + "\n\n").join(sections)
124
+
125
+
126
+ class TaskDetailScreen(ModalScreen[None]):
127
+ """Prompt, response and error for a single task."""
128
+
129
+ BINDINGS = [
130
+ Binding("escape", "close", "Close"),
131
+ Binding("q", "close", "Close", show=False),
132
+ Binding("c", "copy", "Copy response"),
133
+ Binding("y", "copy_thinking", "Copy thinking"),
134
+ ]
135
+
136
+ def __init__(self, context: str, task: dict, *, live: bool = False) -> None:
137
+ super().__init__()
138
+ self.context = context
139
+ self.task_data = task
140
+ self.live = live
141
+
142
+ def _header(self) -> Content:
143
+ live_phase = self.task_data.get("live_phase") if self.live else None
144
+ passed = self.task_data.get("passed")
145
+ error = self.task_data.get("error")
146
+ if live_phase:
147
+ status, style = str(live_phase).upper(), "b blue"
148
+ elif self.task_data.get("loop_killed"):
149
+ status, style = "LOOP KILLED", "b red"
150
+ elif self.task_data.get("timed_out"):
151
+ status, style = "TIMEOUT", "b yellow"
152
+ elif self.task_data.get("length_exceeded"):
153
+ status, style = "LENGTH EXCEEDED", "b yellow"
154
+ elif error:
155
+ status, style = "ERROR", "b yellow"
156
+ elif passed:
157
+ status, style = "PASS", "b green"
158
+ elif float(self.task_data.get("score", 0)) > 0:
159
+ status = f"PARTIAL {float(self.task_data['score']):.0f}%"
160
+ style = "b yellow"
161
+ else:
162
+ status, style = "FAIL", "b red"
163
+ return Content.from_markup(
164
+ "[b]$task[/b] [$style]$status[/$style] [dim]$context[/dim]",
165
+ task=self.task_data.get("task_id", ""),
166
+ status=status,
167
+ style=style,
168
+ context=self.context,
169
+ )
170
+
171
+ def _metadata(self) -> str:
172
+ loop_state = self.task_data.get("loop_state", "unavailable")
173
+ loop_label = (
174
+ "recovered" if self.task_data.get("recovered_cycle") else loop_state
175
+ )
176
+ loop_score = float(self.task_data.get("loop_score", 0.0))
177
+ trace = self.task_data.get("trace_status", "unavailable")
178
+ pieces = [
179
+ fmt_duration(self.task_data.get("response_time_s", 0)),
180
+ f"{self.task_data.get('tok_s', 0)} stream tok/s",
181
+ f"trace {trace}",
182
+ f"loop {loop_label} ({loop_score:.0%})",
183
+ ]
184
+ if self.task_data.get("repair_attempts_used"):
185
+ pieces.append(
186
+ f"repaired after {self.task_data['repair_attempts_used']} feedback turn"
187
+ )
188
+ if float(self.task_data.get("score", 0)) > 0 and not self.task_data.get(
189
+ "passed"
190
+ ):
191
+ pieces.insert(0, f"{float(self.task_data['score']):.0f}% task score")
192
+ if self.task_data.get("error"):
193
+ pieces.append(self.task_data["error"])
194
+ elif self.task_data.get("loop_kill_remaining_s") is not None:
195
+ pieces.append(f"kill in {self.task_data['loop_kill_remaining_s']:.1f}s")
196
+ return " · ".join(pieces)
197
+
198
+ def compose(self) -> ComposeResult:
199
+ with Vertical(id="task-dialog", classes="dialog"):
200
+ yield Static(self._header(), id="task-header")
201
+ yield Static(
202
+ self._metadata(),
203
+ classes="hint",
204
+ id="task-meta",
205
+ )
206
+ with TabbedContent(initial="thinking-tab" if self.live else "response-tab"):
207
+ with TabPane("Thinking", id="thinking-tab"):
208
+ yield TextArea(
209
+ self.task_data.get("thinking", "")
210
+ or "(reasoning trace unavailable or empty)",
211
+ read_only=True,
212
+ soft_wrap=True,
213
+ id="thinking-text",
214
+ )
215
+ with TabPane("Response", id="response-tab"):
216
+ yield TextArea(
217
+ self.task_data.get("response", "") or "(empty response)",
218
+ read_only=True,
219
+ soft_wrap=True,
220
+ id="response-text",
221
+ )
222
+ if self.task_data.get("attempts"):
223
+ with TabPane("Attempts", id="attempts-tab"):
224
+ yield TextArea(
225
+ _attempts_text(self.task_data["attempts"]),
226
+ read_only=True,
227
+ soft_wrap=True,
228
+ id="attempts-text",
229
+ )
230
+ with TabPane("Prompt", id="prompt-tab"):
231
+ yield TextArea(
232
+ self.task_data.get("prompt", ""),
233
+ read_only=True,
234
+ soft_wrap=True,
235
+ id="prompt-text",
236
+ )
237
+ yield Footer()
238
+
239
+ def update_live(self, task: dict, *, finished: bool = False) -> None:
240
+ """Refresh an open task inspector from a streamed snapshot."""
241
+ self.task_data.update(task)
242
+ if finished:
243
+ self.live = False
244
+ self.task_data.pop("live_phase", None)
245
+ if not self.is_mounted:
246
+ return
247
+ try:
248
+ self.query_one("#task-header", Static).update(self._header())
249
+ self.query_one("#task-meta", Static).update(self._metadata())
250
+ _update_live_text(
251
+ self.query_one("#thinking-text", TextArea),
252
+ self.task_data.get("thinking", ""),
253
+ "(reasoning trace unavailable or empty)",
254
+ )
255
+ _update_live_text(
256
+ self.query_one("#response-text", TextArea),
257
+ self.task_data.get("response", ""),
258
+ "(empty response)",
259
+ )
260
+ except NoMatches:
261
+ # A final stream update may race with the modal being dismissed.
262
+ return
263
+
264
+ def action_close(self) -> None:
265
+ self.dismiss(None)
266
+
267
+ def action_copy(self) -> None:
268
+ self.app.copy_to_clipboard(self.task_data.get("response", ""))
269
+ self.notify("Response copied to clipboard", timeout=2)
270
+
271
+ def action_copy_thinking(self) -> None:
272
+ self.app.copy_to_clipboard(self.task_data.get("thinking", ""))
273
+ self.notify("Thinking copied to clipboard", timeout=2)
274
+
275
+
276
+ def _update_live_text(area: TextArea, text: str, empty: str) -> None:
277
+ """Append a live snapshot while preserving the reader's scroll position."""
278
+ value = text or empty
279
+ if value == area.text:
280
+ return
281
+
282
+ follow_tail = area.is_vertical_scroll_end
283
+ scroll_x, scroll_y = area.scroll_offset
284
+ if area.text != empty and value.startswith(area.text):
285
+ area.insert(value[len(area.text) :], area.document.end)
286
+ area.history.clear()
287
+ else:
288
+ # The analyzer retains a bounded tail, so very long streams may
289
+ # eventually drop an old prefix and require a full replacement.
290
+ area.load_text(value)
291
+
292
+ if follow_tail:
293
+ area.scroll_end(animate=False, x_axis=False)
294
+ else:
295
+ area.call_after_refresh(
296
+ lambda: area.scroll_to(
297
+ x=scroll_x,
298
+ y=scroll_y,
299
+ animate=False,
300
+ force=True,
301
+ )
302
+ )
303
+
304
+
305
+ class TemplateCheckScreen(ModalScreen[None]):
306
+ """Compact results for the setup screen's model template check."""
307
+
308
+ BINDINGS = [
309
+ Binding("escape", "close", "Close"),
310
+ Binding("q", "close", "Close", show=False),
311
+ ]
312
+
313
+ def __init__(self, reports: list[TemplateReport]) -> None:
314
+ super().__init__()
315
+ self.reports = reports
316
+
317
+ def compose(self) -> ComposeResult:
318
+ with Vertical(id="template-check-dialog", classes="dialog"):
319
+ yield Static("Chat template checks", classes="card-title")
320
+ yield Static(
321
+ "Reference-free sanity checks using the server's rendered prompt.",
322
+ classes="hint",
323
+ )
324
+ yield DataTable(id="template-check-table", cursor_type="row")
325
+ with Horizontal(classes="dialog-actions"):
326
+ yield Button("Close", id="close", variant="primary")
327
+ yield Footer()
328
+
329
+ def on_mount(self) -> None:
330
+ table = self.query_one("#template-check-table", DataTable)
331
+ table.add_columns("Model", "Status", "Details")
332
+ labels = {
333
+ "pass": Text("PASS", style="green"),
334
+ "warn": Text("WARN", style="yellow"),
335
+ "fail": Text("FAIL", style="red"),
336
+ "unavailable": Text("N/A", style="dim"),
337
+ }
338
+ for report in self.reports:
339
+ table.add_row(report.model, labels[report.status], report.detail)
340
+ table.focus()
341
+
342
+ def on_button_pressed(self, event: Button.Pressed) -> None:
343
+ if event.button.id == "close":
344
+ self.action_close()
345
+
346
+ def action_close(self) -> None:
347
+ self.dismiss(None)
348
+
349
+
350
+ class DatasetsScreen(ModalScreen[bool]):
351
+ """Installed and missing benchmark datasets, with one-click installs.
352
+
353
+ Dismisses with True when anything was installed, so the caller can
354
+ refresh whatever it shows about missing data.
355
+ """
356
+
357
+ BINDINGS = [
358
+ Binding("escape", "close", "Close"),
359
+ Binding("q", "close", "Close", show=False),
360
+ Binding("a", "install_all", "Install all missing"),
361
+ Binding("enter", "install_selected", "Install selected"),
362
+ ]
363
+
364
+ def __init__(self) -> None:
365
+ super().__init__()
366
+ self.installed_any = False
367
+ self.busy = False
368
+
369
+ def compose(self) -> ComposeResult:
370
+ with Vertical(id="datasets-dialog", classes="dialog"):
371
+ yield Static("Datasets", classes="card-title")
372
+ yield Static(
373
+ "Small suites ship with BenchKit. Large ones download once into "
374
+ "a local cache, and a run fetches what it needs automatically.",
375
+ classes="hint",
376
+ )
377
+ yield DataTable(id="datasets-table", cursor_type="row")
378
+ yield Static("", id="datasets-status", classes="hint")
379
+ with Horizontal(classes="dialog-actions"):
380
+ yield Button("Install all missing", id="install-all", variant="primary")
381
+ yield Button("Install selected", id="install-selected")
382
+ yield Button("Close", id="close")
383
+ yield Footer()
384
+
385
+ def on_mount(self) -> None:
386
+ table = self.query_one("#datasets-table", DataTable)
387
+ table.add_column("Benchmark", key="benchmark")
388
+ table.add_column("Status", key="status", width=12)
389
+ table.add_column("Size", key="size", width=10)
390
+ table.add_column("Source", key="source")
391
+ for entry in catalog.statuses():
392
+ table.add_row(
393
+ entry.benchmark,
394
+ self._status_cell(entry),
395
+ catalog.size_label(entry),
396
+ entry.hint
397
+ if entry.kind == "manual" and not entry.installed
398
+ else entry.summary,
399
+ key=entry.benchmark,
400
+ )
401
+ self._sync_actions()
402
+ table.focus()
403
+
404
+ @staticmethod
405
+ def _status_cell(entry: catalog.DatasetStatus) -> Text:
406
+ if entry.kind == "bundled":
407
+ return Text("bundled", style="dim")
408
+ if entry.installed:
409
+ return Text("✓ installed", style="green")
410
+ if entry.kind == "manual":
411
+ return Text("✗ manual", style="red")
412
+ return Text("✗ missing", style="yellow")
413
+
414
+ def _sync_actions(self) -> None:
415
+ missing = catalog.missing()
416
+ self.query_one("#install-all", Button).disabled = self.busy or not missing
417
+ self.query_one("#install-selected", Button).disabled = self.busy
418
+ if not self.busy:
419
+ self.query_one("#datasets-status", Static).update(
420
+ f"{len(missing)} missing: {', '.join(missing)}"
421
+ if missing
422
+ else "Every downloadable dataset is installed."
423
+ )
424
+
425
+ def on_button_pressed(self, event: Button.Pressed) -> None:
426
+ if event.button.id == "install-all":
427
+ self.action_install_all()
428
+ elif event.button.id == "install-selected":
429
+ self.action_install_selected()
430
+ elif event.button.id == "close":
431
+ self.action_close()
432
+
433
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
434
+ event.stop()
435
+ self.action_install_selected()
436
+
437
+ def action_install_all(self) -> None:
438
+ self._start(catalog.missing())
439
+
440
+ def action_install_selected(self) -> None:
441
+ table = self.query_one("#datasets-table", DataTable)
442
+ if table.row_count == 0:
443
+ return
444
+ key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
445
+ entry = catalog.status(str(key))
446
+ if entry.kind == "bundled":
447
+ self.notify(f"{entry.benchmark} ships with BenchKit.", timeout=3)
448
+ elif entry.installed:
449
+ self.notify(f"{entry.benchmark} is already installed.", timeout=3)
450
+ elif entry.kind == "manual":
451
+ self.notify(entry.hint, severity="warning", timeout=8)
452
+ else:
453
+ self._start([entry.benchmark])
454
+
455
+ def _start(self, keys: list[str]) -> None:
456
+ if self.busy or not keys:
457
+ return
458
+ self.busy = True
459
+ self._sync_actions()
460
+ self._install(keys)
461
+
462
+ @work(thread=True, exclusive=True, group="datasets")
463
+ def _install(self, keys: list[str]) -> None:
464
+ failures = []
465
+ for key in keys:
466
+ self.app.call_from_thread(self._progress, key, 0, None)
467
+ try:
468
+ catalog.install(
469
+ key,
470
+ lambda done, total, key=key: self.app.call_from_thread(
471
+ self._progress, key, done, total
472
+ ),
473
+ )
474
+ except Exception as exc:
475
+ failures.append(key)
476
+ self.app.call_from_thread(self._failed, key, str(exc))
477
+ else:
478
+ self.installed_any = True
479
+ self.app.call_from_thread(self._refresh_row, key)
480
+ self.app.call_from_thread(self._finished, keys, failures)
481
+
482
+ def _progress(self, key: str, done: int, total: int | None) -> None:
483
+ detail = f" {fmt_bytes(done)}" if done else "…"
484
+ if done and total:
485
+ detail = f" {fmt_bytes(done)} / {fmt_bytes(total)}"
486
+ self.query_one("#datasets-status", Static).update(f"Installing {key}{detail}")
487
+
488
+ def _refresh_row(self, key: str) -> None:
489
+ table = self.query_one("#datasets-table", DataTable)
490
+ entry = catalog.status(key)
491
+ table.update_cell(key, "status", self._status_cell(entry))
492
+ table.update_cell(key, "size", catalog.size_label(entry))
493
+
494
+ def _failed(self, key: str, message: str) -> None:
495
+ self.notify(f"{key}: {message}", severity="error", timeout=10)
496
+
497
+ def _finished(self, keys: list[str], failures: list[str]) -> None:
498
+ self.busy = False
499
+ self._sync_actions()
500
+ done = len(keys) - len(failures)
501
+ if done:
502
+ self.notify(f"Installed {done} dataset(s).", timeout=4)
503
+
504
+ def action_close(self) -> None:
505
+ if self.busy:
506
+ self.notify("Wait for the install to finish.", timeout=3)
507
+ return
508
+ self.dismiss(self.installed_any)
509
+
510
+
511
+ class ConfirmScreen(ModalScreen[bool]):
512
+ """Yes/no confirmation."""
513
+
514
+ BINDINGS = [
515
+ Binding("escape", "no", "Cancel"),
516
+ Binding("y", "yes", "Yes"),
517
+ Binding("n", "no", "No"),
518
+ ]
519
+
520
+ def __init__(
521
+ self, question: str, detail: str = "", confirm: str = "Confirm"
522
+ ) -> None:
523
+ super().__init__()
524
+ self.question = question
525
+ self.detail = detail
526
+ self.confirm_label = confirm
527
+
528
+ def compose(self) -> ComposeResult:
529
+ with Vertical(id="confirm-dialog", classes="dialog"):
530
+ yield Static(self.question, classes="card-title")
531
+ if self.detail:
532
+ yield Static(self.detail, classes="hint")
533
+ with Horizontal(classes="dialog-actions"):
534
+ yield Button(self.confirm_label, id="yes", variant="error")
535
+ yield Button("Cancel", id="no", variant="primary")
536
+
537
+ def on_button_pressed(self, event: Button.Pressed) -> None:
538
+ self.dismiss(event.button.id == "yes")
539
+
540
+ def action_yes(self) -> None:
541
+ self.dismiss(True)
542
+
543
+ def action_no(self) -> None:
544
+ self.dismiss(False)