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/browser.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Headless Chromium rendering for untrusted, model-generated HTML pages.
|
|
2
|
+
|
|
3
|
+
The renderer is deliberately hostile to the page it loads: every request is
|
|
4
|
+
denied unless its host is on a small module-CDN allowlist, downloads and
|
|
5
|
+
service workers are off, and every navigation, settle window and screenshot
|
|
6
|
+
runs under an explicit deadline. A page that hangs, crashes or wanders off the
|
|
7
|
+
allowlist fails the render instead of stalling the run.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
import threading
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
DEFAULT_VIEWPORT = (1280, 800)
|
|
20
|
+
DEFAULT_SETTLE_S = 4.0
|
|
21
|
+
DEFAULT_TIMEOUT_S = 30.0
|
|
22
|
+
THUMBNAIL_QUALITY = 70
|
|
23
|
+
|
|
24
|
+
# Chromium error codes that mean the request never reached a server: no DNS,
|
|
25
|
+
# no route, no proxy, no TLS. Anything else - an abort, a 404, a page-level
|
|
26
|
+
# fetch the model got wrong - says nothing about the machine.
|
|
27
|
+
CONNECTION_ERRORS = (
|
|
28
|
+
"ERR_NAME_NOT_RESOLVED",
|
|
29
|
+
"ERR_NAME_RESOLUTION_FAILED",
|
|
30
|
+
"ERR_INTERNET_DISCONNECTED",
|
|
31
|
+
"ERR_NETWORK_CHANGED",
|
|
32
|
+
"ERR_CONNECTION_",
|
|
33
|
+
"ERR_ADDRESS_UNREACHABLE",
|
|
34
|
+
"ERR_PROXY_CONNECTION_FAILED",
|
|
35
|
+
"ERR_TUNNEL_CONNECTION_FAILED",
|
|
36
|
+
"ERR_TIMED_OUT",
|
|
37
|
+
"ERR_SSL_",
|
|
38
|
+
"ERR_CERT_",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Hosts that ship the ES module builds a self-contained three.js page can
|
|
42
|
+
# realistically import. Everything else - telemetry, analytics, arbitrary
|
|
43
|
+
# fetches from generated code - is aborted before it leaves the browser.
|
|
44
|
+
DEFAULT_ALLOWED_HOSTS = (
|
|
45
|
+
"cdn.jsdelivr.net",
|
|
46
|
+
"cdnjs.cloudflare.com",
|
|
47
|
+
"esm.sh",
|
|
48
|
+
"threejs.org",
|
|
49
|
+
"unpkg.com",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Recorded before any page script runs, so a page cannot hide a missing canvas
|
|
53
|
+
# or a swallowed rejection by monkey-patching the same APIs afterwards.
|
|
54
|
+
_INSTRUMENTATION = """
|
|
55
|
+
(() => {
|
|
56
|
+
const state = { frames: 0, contexts: [], rejections: [] };
|
|
57
|
+
Object.defineProperty(window, "__benchkit", { value: state });
|
|
58
|
+
const raf = window.requestAnimationFrame.bind(window);
|
|
59
|
+
window.requestAnimationFrame = callback => raf(time => {
|
|
60
|
+
state.frames += 1;
|
|
61
|
+
return callback(time);
|
|
62
|
+
});
|
|
63
|
+
const getContext = HTMLCanvasElement.prototype.getContext;
|
|
64
|
+
HTMLCanvasElement.prototype.getContext = function (type, ...rest) {
|
|
65
|
+
const context = getContext.call(this, type, ...rest);
|
|
66
|
+
if (context) state.contexts.push(String(type));
|
|
67
|
+
return context;
|
|
68
|
+
};
|
|
69
|
+
window.addEventListener("unhandledrejection", event => {
|
|
70
|
+
state.rejections.push(String((event.reason && event.reason.stack) || event.reason));
|
|
71
|
+
});
|
|
72
|
+
})();
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
_PAGE_STATE = """
|
|
76
|
+
() => {
|
|
77
|
+
const state = window.__benchkit || { frames: 0, contexts: [], rejections: [] };
|
|
78
|
+
const canvases = [...document.querySelectorAll("canvas")].map(canvas => ({
|
|
79
|
+
width: canvas.width,
|
|
80
|
+
height: canvas.height,
|
|
81
|
+
client_width: canvas.clientWidth,
|
|
82
|
+
client_height: canvas.clientHeight,
|
|
83
|
+
}));
|
|
84
|
+
return {
|
|
85
|
+
frames: state.frames,
|
|
86
|
+
contexts: [...new Set(state.contexts)],
|
|
87
|
+
rejections: state.rejections,
|
|
88
|
+
canvases,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _env_float(name: str, default: float) -> float:
|
|
95
|
+
try:
|
|
96
|
+
return float(os.environ.get(name, str(default)))
|
|
97
|
+
except ValueError:
|
|
98
|
+
return default
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
102
|
+
raw = os.environ.get(name)
|
|
103
|
+
if raw is None:
|
|
104
|
+
return default
|
|
105
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def allowed_hosts() -> tuple[str, ...]:
|
|
109
|
+
"""Hosts the rendered page may reach, or none in strict offline mode."""
|
|
110
|
+
if _env_bool("BENCHKIT_RENDER_OFFLINE", False):
|
|
111
|
+
return ()
|
|
112
|
+
raw = os.environ.get("BENCHKIT_RENDER_ALLOWED_HOSTS")
|
|
113
|
+
if raw is None:
|
|
114
|
+
return DEFAULT_ALLOWED_HOSTS
|
|
115
|
+
return tuple(host.strip().lower() for host in raw.split(",") if host.strip())
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _host_allowed(url: str, hosts: tuple[str, ...]) -> bool:
|
|
119
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
120
|
+
if not host:
|
|
121
|
+
return False
|
|
122
|
+
return any(host == allowed or host.endswith(f".{allowed}") for allowed in hosts)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def chromium_executable() -> str | None:
|
|
126
|
+
"""An existing Chromium build to use instead of Playwright's own download."""
|
|
127
|
+
return os.environ.get("BENCHKIT_RENDER_CHROMIUM") or None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class RenderResult:
|
|
132
|
+
"""Everything one page load told us about the generated file."""
|
|
133
|
+
|
|
134
|
+
rendered: bool = False
|
|
135
|
+
console_errors: list[str] = field(default_factory=list)
|
|
136
|
+
page_errors: list[str] = field(default_factory=list)
|
|
137
|
+
blocked_requests: list[str] = field(default_factory=list)
|
|
138
|
+
failed_requests: list[str] = field(default_factory=list)
|
|
139
|
+
# Requests BenchKit allowed that never reached a server at all.
|
|
140
|
+
unreachable: list[str] = field(default_factory=list)
|
|
141
|
+
canvases: list[dict] = field(default_factory=list)
|
|
142
|
+
contexts: list[str] = field(default_factory=list)
|
|
143
|
+
frames: int = 0
|
|
144
|
+
screenshot: str = ""
|
|
145
|
+
thumbnail: str = ""
|
|
146
|
+
# Set when BenchKit itself could not render (no Playwright, no browser).
|
|
147
|
+
# Callers report this as a harness error rather than a failed page.
|
|
148
|
+
error: str = ""
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def diagnostics(self) -> str:
|
|
152
|
+
"""Console and network output, in the order a developer would read it."""
|
|
153
|
+
lines: list[str] = []
|
|
154
|
+
for message in self.page_errors:
|
|
155
|
+
lines.append(f"[uncaught] {message}")
|
|
156
|
+
for message in self.console_errors:
|
|
157
|
+
lines.append(f"[console] {message}")
|
|
158
|
+
for url in self.blocked_requests:
|
|
159
|
+
lines.append(f"[blocked] {url}")
|
|
160
|
+
for url in self.failed_requests:
|
|
161
|
+
lines.append(f"[network] {url}")
|
|
162
|
+
if not self.canvases:
|
|
163
|
+
lines.append("[page] no <canvas> element was present after the wait")
|
|
164
|
+
elif not any(
|
|
165
|
+
canvas.get("width") and canvas.get("height") for canvas in self.canvases
|
|
166
|
+
):
|
|
167
|
+
lines.append("[page] the <canvas> element has zero drawing-buffer size")
|
|
168
|
+
if self.canvases and not self.contexts:
|
|
169
|
+
lines.append("[page] no rendering context was acquired on any canvas")
|
|
170
|
+
return "\n".join(lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def launch_args() -> list[str]:
|
|
174
|
+
args = [
|
|
175
|
+
# SwiftShader keeps WebGL working on headless machines without a GPU.
|
|
176
|
+
"--use-gl=angle",
|
|
177
|
+
"--use-angle=swiftshader",
|
|
178
|
+
"--enable-unsafe-swiftshader",
|
|
179
|
+
"--disable-dev-shm-usage",
|
|
180
|
+
"--mute-audio",
|
|
181
|
+
]
|
|
182
|
+
if _env_bool("BENCHKIT_RENDER_NO_SANDBOX", False):
|
|
183
|
+
args.append("--no-sandbox")
|
|
184
|
+
return args
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _render(
|
|
188
|
+
page_path: Path,
|
|
189
|
+
screenshot: Path,
|
|
190
|
+
thumbnail: Path,
|
|
191
|
+
viewport: tuple[int, int],
|
|
192
|
+
settle_s: float,
|
|
193
|
+
timeout_s: float,
|
|
194
|
+
result: RenderResult,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Load one file:// page and fill ``result`` in place."""
|
|
197
|
+
try:
|
|
198
|
+
from playwright.sync_api import Error as PlaywrightError
|
|
199
|
+
from playwright.sync_api import sync_playwright
|
|
200
|
+
except ImportError:
|
|
201
|
+
result.error = (
|
|
202
|
+
"playwright is not installed; install the browser extra "
|
|
203
|
+
"(uv sync --extra browser) and run 'playwright install chromium'"
|
|
204
|
+
)
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
hosts = allowed_hosts()
|
|
208
|
+
page_url = page_path.resolve().as_uri()
|
|
209
|
+
timeout_ms = timeout_s * 1000
|
|
210
|
+
|
|
211
|
+
def guard(route, request) -> None:
|
|
212
|
+
"""Deny anything the page asks for beyond itself and the allowlist."""
|
|
213
|
+
url = request.url
|
|
214
|
+
if url == page_url or url.startswith(("data:", "blob:", "about:")):
|
|
215
|
+
route.continue_()
|
|
216
|
+
return
|
|
217
|
+
if _host_allowed(url, hosts):
|
|
218
|
+
route.continue_()
|
|
219
|
+
return
|
|
220
|
+
result.blocked_requests.append(url)
|
|
221
|
+
route.abort()
|
|
222
|
+
|
|
223
|
+
executable = chromium_executable()
|
|
224
|
+
try:
|
|
225
|
+
with sync_playwright() as playwright:
|
|
226
|
+
browser = playwright.chromium.launch(
|
|
227
|
+
args=launch_args(),
|
|
228
|
+
executable_path=executable,
|
|
229
|
+
)
|
|
230
|
+
try:
|
|
231
|
+
context = browser.new_context(
|
|
232
|
+
viewport={"width": viewport[0], "height": viewport[1]},
|
|
233
|
+
accept_downloads=False,
|
|
234
|
+
service_workers="block",
|
|
235
|
+
)
|
|
236
|
+
context.set_default_timeout(timeout_ms)
|
|
237
|
+
context.add_init_script(_INSTRUMENTATION)
|
|
238
|
+
context.route("**/*", guard)
|
|
239
|
+
page = context.new_page()
|
|
240
|
+
page.on(
|
|
241
|
+
"console",
|
|
242
|
+
lambda message: (
|
|
243
|
+
result.console_errors.append(message.text)
|
|
244
|
+
if message.type == "error"
|
|
245
|
+
else None
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
page.on(
|
|
249
|
+
"pageerror", lambda error: result.page_errors.append(str(error))
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def on_failed(request) -> None:
|
|
253
|
+
if request.url in result.blocked_requests:
|
|
254
|
+
return
|
|
255
|
+
reason = request.failure or ""
|
|
256
|
+
result.failed_requests.append(
|
|
257
|
+
f"{request.url} ({reason})" if reason else request.url
|
|
258
|
+
)
|
|
259
|
+
if any(code in reason for code in CONNECTION_ERRORS):
|
|
260
|
+
result.unreachable.append(request.url)
|
|
261
|
+
|
|
262
|
+
page.on("requestfailed", on_failed)
|
|
263
|
+
page.goto(page_url, wait_until="load", timeout=timeout_ms)
|
|
264
|
+
page.wait_for_timeout(settle_s * 1000)
|
|
265
|
+
state = page.evaluate(_PAGE_STATE)
|
|
266
|
+
result.frames = int(state.get("frames") or 0)
|
|
267
|
+
result.contexts = [str(item) for item in state.get("contexts") or []]
|
|
268
|
+
result.canvases = list(state.get("canvases") or [])
|
|
269
|
+
result.page_errors.extend(
|
|
270
|
+
str(item) for item in state.get("rejections") or []
|
|
271
|
+
)
|
|
272
|
+
page.screenshot(path=str(screenshot), type="png", timeout=timeout_ms)
|
|
273
|
+
thumbnail.write_bytes(
|
|
274
|
+
page.screenshot(
|
|
275
|
+
type="jpeg",
|
|
276
|
+
quality=THUMBNAIL_QUALITY,
|
|
277
|
+
timeout=timeout_ms,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
result.screenshot = str(screenshot)
|
|
281
|
+
result.thumbnail = str(thumbnail)
|
|
282
|
+
finally:
|
|
283
|
+
browser.close()
|
|
284
|
+
except PlaywrightError as exc:
|
|
285
|
+
message = str(exc).strip().splitlines()[0] if str(exc).strip() else repr(exc)
|
|
286
|
+
if "executable doesn't exist" in message.lower():
|
|
287
|
+
result.error = (
|
|
288
|
+
"chromium is not installed for playwright (run "
|
|
289
|
+
f"'playwright install chromium', or set BENCHKIT_RENDER_CHROMIUM "
|
|
290
|
+
f"to an existing build): {message}"
|
|
291
|
+
)
|
|
292
|
+
elif screenshot.exists():
|
|
293
|
+
# The page loaded and was captured; the failure is the page's.
|
|
294
|
+
result.page_errors.append(message)
|
|
295
|
+
else:
|
|
296
|
+
result.page_errors.append(f"page load failed: {message}")
|
|
297
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
298
|
+
result.error = f"{type(exc).__name__}: {exc}"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def render_page(
|
|
302
|
+
page_path: Path,
|
|
303
|
+
screenshot: Path,
|
|
304
|
+
thumbnail: Path,
|
|
305
|
+
*,
|
|
306
|
+
viewport: tuple[int, int] = DEFAULT_VIEWPORT,
|
|
307
|
+
settle_s: float | None = None,
|
|
308
|
+
timeout_s: float | None = None,
|
|
309
|
+
) -> RenderResult:
|
|
310
|
+
"""Render one HTML file and report whether the page came up clean.
|
|
311
|
+
|
|
312
|
+
The page is judged rendered when it raised no uncaught exception, logged no
|
|
313
|
+
console error, reached no blocked host, and ended with a sized canvas that
|
|
314
|
+
actually acquired a drawing context.
|
|
315
|
+
"""
|
|
316
|
+
settle_s = DEFAULT_SETTLE_S if settle_s is None else settle_s
|
|
317
|
+
timeout_s = DEFAULT_TIMEOUT_S if timeout_s is None else timeout_s
|
|
318
|
+
result = RenderResult()
|
|
319
|
+
|
|
320
|
+
# Playwright's sync API refuses to run inside a thread with a live asyncio
|
|
321
|
+
# loop, which is exactly where the TUI evaluates tasks, so every render
|
|
322
|
+
# gets a clean thread of its own.
|
|
323
|
+
worker = threading.Thread(
|
|
324
|
+
target=_render,
|
|
325
|
+
args=(
|
|
326
|
+
page_path,
|
|
327
|
+
screenshot,
|
|
328
|
+
thumbnail,
|
|
329
|
+
viewport,
|
|
330
|
+
settle_s,
|
|
331
|
+
timeout_s,
|
|
332
|
+
result,
|
|
333
|
+
),
|
|
334
|
+
name="benchkit-render",
|
|
335
|
+
daemon=True,
|
|
336
|
+
)
|
|
337
|
+
worker.start()
|
|
338
|
+
# Navigation, settle and two screenshots each carry their own deadline;
|
|
339
|
+
# this is the backstop for a browser that stops answering entirely.
|
|
340
|
+
worker.join(timeout=(timeout_s * 3) + settle_s + 30.0)
|
|
341
|
+
if worker.is_alive():
|
|
342
|
+
result.error = (
|
|
343
|
+
f"headless render did not finish within its {timeout_s:g}s budget"
|
|
344
|
+
)
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
# The verdict is what the page achieved: no uncaught exception, and a sized
|
|
348
|
+
# canvas that acquired a drawing context. Console output and blocked hosts
|
|
349
|
+
# are reported and fed back for repair, but a scene that draws is a scene
|
|
350
|
+
# that rendered - a stray console.error or an analytics ping it never
|
|
351
|
+
# needed does not undo that.
|
|
352
|
+
result.rendered = bool(
|
|
353
|
+
not result.error
|
|
354
|
+
and not result.page_errors
|
|
355
|
+
and result.contexts
|
|
356
|
+
and any(
|
|
357
|
+
int(canvas.get("width") or 0) > 0 and int(canvas.get("height") or 0) > 0
|
|
358
|
+
for canvas in result.canvases
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# A scene that needs nothing but a working headless WebGL stack. When this
|
|
365
|
+
# cannot render, no model output can either, and the fault is the machine's.
|
|
366
|
+
PROBE_PAGE = """<!doctype html><html><head><style>
|
|
367
|
+
html,body{margin:0;height:100%;overflow:hidden}canvas{display:block}
|
|
368
|
+
</style></head><body><canvas id="probe"></canvas><script>
|
|
369
|
+
const canvas = document.getElementById("probe");
|
|
370
|
+
canvas.width = window.innerWidth;
|
|
371
|
+
canvas.height = window.innerHeight;
|
|
372
|
+
const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
|
|
373
|
+
if (!gl) throw new Error("no WebGL context: this browser has no working GPU or software rasterizer");
|
|
374
|
+
gl.clearColor(0.1, 0.5, 0.4, 1);
|
|
375
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
376
|
+
requestAnimationFrame(() => {});
|
|
377
|
+
</script></body></html>
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
_PROBE_LOCK = threading.Lock()
|
|
381
|
+
_PROBE: tuple[bool, str] | None = None
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def probe_environment(force: bool = False) -> tuple[bool, str]:
|
|
385
|
+
"""Check once whether this machine can render WebGL at all.
|
|
386
|
+
|
|
387
|
+
Returns ``(ok, detail)``. The result is cached for the process: it answers
|
|
388
|
+
a question about the machine, not about any one page.
|
|
389
|
+
"""
|
|
390
|
+
global _PROBE
|
|
391
|
+
with _PROBE_LOCK:
|
|
392
|
+
if _PROBE is not None and not force:
|
|
393
|
+
return _PROBE
|
|
394
|
+
|
|
395
|
+
with tempfile.TemporaryDirectory(prefix="benchkit-render-probe-") as directory:
|
|
396
|
+
root = Path(directory)
|
|
397
|
+
page = root / "probe.html"
|
|
398
|
+
page.write_text(PROBE_PAGE, encoding="utf-8")
|
|
399
|
+
outcome = render_page(
|
|
400
|
+
page,
|
|
401
|
+
root / "probe.png",
|
|
402
|
+
root / "probe.jpg",
|
|
403
|
+
settle_s=0.5,
|
|
404
|
+
timeout_s=render_timeout_s(),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
if outcome.error:
|
|
408
|
+
answer = (False, outcome.error)
|
|
409
|
+
elif outcome.rendered:
|
|
410
|
+
answer = (
|
|
411
|
+
True,
|
|
412
|
+
f"headless WebGL is available ({', '.join(outcome.contexts)})",
|
|
413
|
+
)
|
|
414
|
+
else:
|
|
415
|
+
answer = (
|
|
416
|
+
False,
|
|
417
|
+
"headless Chromium could not render a plain WebGL canvas: "
|
|
418
|
+
+ (outcome.diagnostics.replace("\n", " · ") or "no diagnostics"),
|
|
419
|
+
)
|
|
420
|
+
_PROBE = answer
|
|
421
|
+
return answer
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def render_settle_s() -> float:
|
|
425
|
+
return _env_float("BENCHKIT_RENDER_SETTLE", DEFAULT_SETTLE_S)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def render_timeout_s() -> float:
|
|
429
|
+
return _env_float("BENCHKIT_RENDER_TIMEOUT", DEFAULT_TIMEOUT_S)
|
benchkit/catalog.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Which benchmark datasets are installed, and installing the missing ones.
|
|
2
|
+
|
|
3
|
+
Shared by ``benchkit datasets`` and the TUI's datasets dialog. A benchmark
|
|
4
|
+
whose class has no ``data_source`` ships its data in the wheel and is always
|
|
5
|
+
installed. Nothing here loads tasks, so listing never triggers a download.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
from benchkit.benchmarks import REGISTRY
|
|
17
|
+
from benchkit.benchmarks.downloads import Dataset, Progress, fmt_bytes
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class DatasetStatus:
|
|
22
|
+
benchmark: str
|
|
23
|
+
kind: Literal["bundled", "download", "manual"]
|
|
24
|
+
installed: bool
|
|
25
|
+
summary: str
|
|
26
|
+
size: int | None
|
|
27
|
+
location: str
|
|
28
|
+
hint: str = ""
|
|
29
|
+
approximate: bool = False
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def installable(self) -> bool:
|
|
33
|
+
return self.kind == "download" and not self.installed
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def source(key: str) -> Dataset | None:
|
|
37
|
+
return getattr(REGISTRY[key], "data_source", None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def status(key: str) -> DatasetStatus:
|
|
41
|
+
data = source(key)
|
|
42
|
+
if data is None:
|
|
43
|
+
return DatasetStatus(key, "bundled", True, "bundled with BenchKit", None, "")
|
|
44
|
+
try:
|
|
45
|
+
installed = bool(data.is_installed())
|
|
46
|
+
except Exception:
|
|
47
|
+
installed = False
|
|
48
|
+
return DatasetStatus(
|
|
49
|
+
benchmark=key,
|
|
50
|
+
kind="download" if data.install is not None else "manual",
|
|
51
|
+
installed=installed,
|
|
52
|
+
summary=data.summary,
|
|
53
|
+
size=data.size,
|
|
54
|
+
location=str(data.location()),
|
|
55
|
+
hint=data.hint,
|
|
56
|
+
approximate=data.approximate,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def size_label(entry: DatasetStatus) -> str:
|
|
61
|
+
"""What the data costs on disk, or what installing it will download.
|
|
62
|
+
|
|
63
|
+
Installed data is measured, so the figure is right whatever upstream
|
|
64
|
+
shipped. Missing data shows its declared download size with a ``↓``, or
|
|
65
|
+
``?`` when the suite declares none. Measuring walks the cache, so this is
|
|
66
|
+
kept out of ``status`` and only runs for listings.
|
|
67
|
+
"""
|
|
68
|
+
if entry.kind == "bundled":
|
|
69
|
+
path = _bundled_file(entry.benchmark)
|
|
70
|
+
return fmt_bytes(path.stat().st_size) if path else "in code"
|
|
71
|
+
if entry.installed:
|
|
72
|
+
return fmt_bytes(_disk_usage(Path(entry.location)))
|
|
73
|
+
if entry.kind == "manual":
|
|
74
|
+
return ""
|
|
75
|
+
if entry.size is None:
|
|
76
|
+
return "?"
|
|
77
|
+
return f"↓ {'~' if entry.approximate else ''}{fmt_bytes(entry.size)}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _bundled_file(key: str) -> Path | None:
|
|
81
|
+
module = importlib.import_module(REGISTRY[key].__module__)
|
|
82
|
+
dataset = getattr(module, "DATASET", None)
|
|
83
|
+
if dataset is None or isinstance(dataset, str):
|
|
84
|
+
return None
|
|
85
|
+
path = Path(str(dataset))
|
|
86
|
+
return path if path.is_file() else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _disk_usage(path: Path) -> int:
|
|
90
|
+
if path.is_file():
|
|
91
|
+
return path.stat().st_size
|
|
92
|
+
return sum(item.stat().st_size for item in path.rglob("*") if item.is_file())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def statuses(keys: Iterable[str] | None = None) -> list[DatasetStatus]:
|
|
96
|
+
return [status(key) for key in (REGISTRY if keys is None else keys)]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def missing(keys: Iterable[str] | None = None) -> list[str]:
|
|
100
|
+
"""Benchmarks whose data is absent but can be fetched automatically.
|
|
101
|
+
|
|
102
|
+
Suites sharing one source (ruler and ruler-full) are listed once.
|
|
103
|
+
"""
|
|
104
|
+
seen: set[int] = set()
|
|
105
|
+
keys_out = []
|
|
106
|
+
for entry in statuses(keys):
|
|
107
|
+
identity = id(source(entry.benchmark))
|
|
108
|
+
if entry.installable and identity not in seen:
|
|
109
|
+
seen.add(identity)
|
|
110
|
+
keys_out.append(entry.benchmark)
|
|
111
|
+
return keys_out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def install(key: str, progress: Progress | None = None) -> None:
|
|
115
|
+
"""Fetch one benchmark's data. Raises with an actionable message."""
|
|
116
|
+
data = source(key)
|
|
117
|
+
if data is None:
|
|
118
|
+
return
|
|
119
|
+
if data.install is None:
|
|
120
|
+
raise RuntimeError(f"{key} cannot be installed automatically: {data.hint}")
|
|
121
|
+
data.install(progress)
|