pluginbench 0.5.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 (58) hide show
  1. pluginbench/__init__.py +3 -0
  2. pluginbench/__main__.py +3 -0
  3. pluginbench/assessment-charts.html +790 -0
  4. pluginbench/assessment.py +316 -0
  5. pluginbench/assessment_charts.py +1225 -0
  6. pluginbench/assessment_metrics.py +1956 -0
  7. pluginbench/assessment_models.py +127 -0
  8. pluginbench/baselines.py +792 -0
  9. pluginbench/claude.py +374 -0
  10. pluginbench/cli.py +672 -0
  11. pluginbench/code_diff.py +207 -0
  12. pluginbench/codex.py +451 -0
  13. pluginbench/codex_events.py +585 -0
  14. pluginbench/collectors.py +591 -0
  15. pluginbench/compare.py +927 -0
  16. pluginbench/components.py +129 -0
  17. pluginbench/coverage_report.py +610 -0
  18. pluginbench/datasets.py +207 -0
  19. pluginbench/evaluation.example.yaml +45 -0
  20. pluginbench/evaluation.py +1651 -0
  21. pluginbench/evaluation_report.py +1004 -0
  22. pluginbench/fallow_parse.mjs +101 -0
  23. pluginbench/harbor_backend.py +2946 -0
  24. pluginbench/isolation.py +314 -0
  25. pluginbench/judge.py +683 -0
  26. pluginbench/language_analysis.py +915 -0
  27. pluginbench/ledger.py +217 -0
  28. pluginbench/measurement_profiles.py +616 -0
  29. pluginbench/native_treatment_runtime.js +221 -0
  30. pluginbench/native_treatments.py +155 -0
  31. pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
  32. pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
  33. pluginbench/observer_data/go-analysis-v1/main.go +64 -0
  34. pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
  35. pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
  36. pluginbench/parallel.py +101 -0
  37. pluginbench/processes.py +100 -0
  38. pluginbench/remeasure.py +992 -0
  39. pluginbench/run_lock.py +227 -0
  40. pluginbench/run_status.py +518 -0
  41. pluginbench/study.py +1858 -0
  42. pluginbench/study_review.py +924 -0
  43. pluginbench/submissions.py +486 -0
  44. pluginbench/task_sources.py +403 -0
  45. pluginbench/treatments.py +388 -0
  46. pluginbench/trial_containers.py +94 -0
  47. pluginbench/vendor/ECHARTS-LICENSE +222 -0
  48. pluginbench/vendor/ECHARTS-NOTICE +5 -0
  49. pluginbench/vendor/ZRENDER-LICENSE +29 -0
  50. pluginbench/vendor/echarts.min.js +48 -0
  51. pluginbench-0.5.0.dist-info/METADATA +294 -0
  52. pluginbench-0.5.0.dist-info/RECORD +58 -0
  53. pluginbench-0.5.0.dist-info/WHEEL +4 -0
  54. pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
  55. pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
  56. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
  57. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
  58. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ZRENDER-LICENSE +29 -0
@@ -0,0 +1,403 @@
1
+ """Remote task references and an optional fzf picker; no model calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import errno
7
+ import hashlib
8
+ import importlib
9
+ import json
10
+ import os
11
+ import re
12
+ import shlex
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import tomllib
18
+ from collections.abc import Coroutine
19
+ from pathlib import Path
20
+ from urllib.parse import urlsplit
21
+
22
+ import yaml
23
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, TypeAdapter, model_validator
24
+
25
+ from pluginbench.harbor_backend import native_task_assets as task_fingerprint
26
+
27
+
28
+ class _CatalogMetadata(BaseModel):
29
+ name: str
30
+ version: str
31
+ task_ids: list[dict[str, str]]
32
+
33
+
34
+ class _DownloadedTask(BaseModel):
35
+ path: Path
36
+ resolved_git_commit_id: str | None = None
37
+
38
+
39
+ def _error_messages(error: Exception) -> str:
40
+ if isinstance(error, ExceptionGroup):
41
+ return "; ".join(_error_messages(child) for child in error.exceptions)
42
+ return f"{type(error).__name__}: {error}"
43
+
44
+
45
+ def _harbor_response(request: Coroutine[object, object, object]) -> BaseModel:
46
+ try:
47
+ response = asyncio.run(request)
48
+ except Exception as error:
49
+ raise ValueError(f"Harbor task lookup/download failed: {_error_messages(error)}") from error
50
+ if not isinstance(response, BaseModel):
51
+ raise ValueError("Harbor returned an invalid task response")
52
+ return response
53
+
54
+
55
+ def cache_root() -> Path:
56
+ return Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) / "pluginbench/tasks"
57
+
58
+
59
+ def github_task(url: str) -> tuple[str, str]:
60
+ parsed = urlsplit(url)
61
+ parts = parsed.path.strip("/").split("/")
62
+ if (
63
+ parsed.scheme != "https"
64
+ or parsed.netloc != "github.com"
65
+ or parsed.query
66
+ or parsed.fragment
67
+ or len(parts) != 6
68
+ or parts[2] != "tree"
69
+ or parts[4] != "tasks"
70
+ or any(part in {"", ".", ".."} for part in parts)
71
+ or any(
72
+ not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", parts[index]) for index in (0, 1, 3, 5)
73
+ )
74
+ ):
75
+ raise ValueError(
76
+ "use a GitHub task-folder URL: https://github.com/owner/repo/tree/ref/tasks/id"
77
+ )
78
+ return parts[5], parts[3]
79
+
80
+
81
+ def dataset_catalog(
82
+ dataset: str, version: str | None = None
83
+ ) -> tuple[str, str, list[dict[str, str]]]:
84
+ # Imported here, not at the top: importing the CLI and rebuilding reports must not load Harbor.
85
+ client = importlib.import_module("harbor.registry.client.package").PackageDatasetClient()
86
+
87
+ if version and "@" in dataset:
88
+ raise ValueError("provide the dataset version once")
89
+ response = _harbor_response(
90
+ client.get_dataset_metadata(f"{dataset}@{version}" if version else dataset)
91
+ )
92
+ metadata = _CatalogMetadata.model_validate_json(response.model_dump_json())
93
+ if not metadata.version or not re.fullmatch(r"sha256:[0-9a-f]{64}", metadata.version):
94
+ raise ValueError("Harbor dataset did not resolve to an immutable version")
95
+ references = []
96
+ for task in metadata.task_ids:
97
+ if (
98
+ set(task) != {"org", "name", "ref"}
99
+ or not re.fullmatch(r"sha256:[0-9a-f]{64}", task["ref"])
100
+ or any(
101
+ not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", task[key])
102
+ for key in ("org", "name")
103
+ )
104
+ ):
105
+ raise ValueError("dataset contains an unsupported or unresolved task reference")
106
+ references.append(task)
107
+ return metadata.name, metadata.version, references
108
+
109
+
110
+ def download_tasks(references: list[dict[str, str]]) -> list[tuple[Path, dict[str, str]]]:
111
+ destination = _snapshot_destination(references)
112
+ if destination.exists():
113
+ _validate_snapshot(destination, references)
114
+ return [(destination / "tasks" / task_name(ref), ref) for ref in references]
115
+ ids = importlib.import_module("harbor.models.task.id")
116
+ client = importlib.import_module("harbor.tasks.client").TaskClient()
117
+
118
+ tasks = [
119
+ ids.PackageTaskId.model_validate(ref) if "org" in ref else ids.GitTaskId.model_validate(ref)
120
+ for ref in references
121
+ ]
122
+ # Harbor trusts existing cache directories without hashing them. Import fresh bytes once;
123
+ # later reads use PluginBench's own complete asset fingerprint and origin receipt.
124
+ cache_root().mkdir(parents=True, exist_ok=True)
125
+ with tempfile.TemporaryDirectory(prefix=".download-", dir=cache_root()) as temporary:
126
+ result = _harbor_response(
127
+ client.download_tasks(tasks, output_dir=Path(temporary), overwrite=True)
128
+ )
129
+ receipts = TypeAdapter(list[_DownloadedTask]).validate_python(
130
+ result.model_dump(mode="json")["results"]
131
+ )
132
+ resolved = []
133
+ for ref, downloaded in zip(references, receipts, strict=True):
134
+ if "git_url" in ref:
135
+ sha = downloaded.resolved_git_commit_id
136
+ if not sha or not re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", sha):
137
+ raise ValueError("Git task did not resolve to an immutable commit")
138
+ ref = {**ref, "git_commit_id": sha}
139
+ resolved.append((downloaded.path, ref))
140
+ destination = materialize_tasks(resolved)
141
+ return [(destination / "tasks" / task_name(ref), ref) for _, ref in resolved]
142
+
143
+
144
+ def task_name(reference: dict[str, str]) -> str:
145
+ return reference["name"] if "org" in reference else Path(reference["path"]).name
146
+
147
+
148
+ def _snapshot_destination(references: list[dict[str, str]]) -> Path:
149
+ identity = hashlib.sha256(json.dumps(references, sort_keys=True).encode()).hexdigest()
150
+ return cache_root() / "native-snapshots" / identity
151
+
152
+
153
+ def _validate_snapshot(destination: Path, references: list[dict[str, str]]) -> None:
154
+ if destination.is_symlink() or any(
155
+ (destination / name).is_symlink() for name in (".pluginbench-source.json", "tasks")
156
+ ):
157
+ raise ValueError("cached task provenance cannot follow symlinks")
158
+ provenance = json.loads((destination / ".pluginbench-source.json").read_text())
159
+ if provenance["references"] != references:
160
+ raise ValueError("cached task references changed")
161
+ names = {task_name(ref) for ref in references}
162
+ if set(provenance["task_hashes"]) != names:
163
+ raise ValueError("cached task inventory changed")
164
+ for name in names:
165
+ if task_fingerprint(destination / "tasks" / name) != provenance["task_hashes"][name]:
166
+ raise ValueError(f"cached task assets changed: {name}")
167
+
168
+
169
+ def materialize_tasks(downloads: list[tuple[Path, dict[str, str]]]) -> Path:
170
+ """Publish full task assets atomically and revalidate every later cache read."""
171
+ references = [ref for _, ref in downloads]
172
+ destination = _snapshot_destination(references)
173
+ parent = destination.parent
174
+ parent.mkdir(parents=True, exist_ok=True)
175
+ if destination.is_symlink():
176
+ raise ValueError("task snapshot cache cannot follow symlinks")
177
+ if destination.exists():
178
+ _validate_snapshot(destination, references)
179
+ return destination
180
+ with tempfile.TemporaryDirectory(prefix=".snapshot-", dir=parent) as temporary:
181
+ staging = Path(temporary) / "source"
182
+ (staging / "tasks").mkdir(parents=True)
183
+ fingerprints = {}
184
+ for source, ref in downloads:
185
+ name = task_name(ref)
186
+ fingerprint = task_fingerprint(source)
187
+ importlib.import_module("harbor.models.task.task").Task(source)
188
+ target = staging / "tasks" / name
189
+ shutil.copytree(source, target, symlinks=True)
190
+ if task_fingerprint(target) != fingerprint or task_fingerprint(source) != fingerprint:
191
+ raise ValueError("task assets changed while freezing input")
192
+ fingerprints[name] = fingerprint
193
+ (staging / ".pluginbench-source.json").write_text(
194
+ json.dumps({"references": references, "task_hashes": fingerprints}, indent=2) + "\n"
195
+ )
196
+ try:
197
+ staging.rename(destination)
198
+ except OSError as error:
199
+ if error.errno not in {errno.EEXIST, errno.ENOTEMPTY}:
200
+ raise
201
+ # Another picker/run may have published the same immutable selection.
202
+ return materialize_tasks(downloads)
203
+ return destination
204
+
205
+
206
+ class Tasks(BaseModel):
207
+ model_config = ConfigDict(extra="forbid")
208
+ source: Path | None = Field(
209
+ default=None,
210
+ description="Selects a local task directory instead of a dataset or GitHub URLs.",
211
+ )
212
+ dataset: str | None = Field(
213
+ default=None,
214
+ description="Selects a Harbor dataset instead of a local source or GitHub URLs.",
215
+ )
216
+ version: str | None = Field(
217
+ default=None,
218
+ description="Selects the dataset version, which resolves to an immutable content hash.",
219
+ )
220
+ ids: list[str] = Field(
221
+ default=[], description="Selects unique task IDs from the local source or dataset."
222
+ )
223
+ urls: list[str] = Field(
224
+ default=[],
225
+ description="Selects unique GitHub task-folder URLs whose refs resolve to commits.",
226
+ )
227
+ _resolved: Path | None = PrivateAttr(default=None)
228
+ _references: list[dict[str, str]] = PrivateAttr(default_factory=list)
229
+
230
+ @property
231
+ def task_ids(self) -> list[str]:
232
+ return [github_task(url)[0] for url in self.urls] or [
233
+ value.split("/")[-1] for value in self.ids
234
+ ]
235
+
236
+ @model_validator(mode="after")
237
+ def coherent(self) -> Tasks:
238
+ if sum((self.source is not None, self.dataset is not None, bool(self.urls))) != 1:
239
+ raise ValueError("choose exactly one task source: source, dataset, or urls")
240
+ if self.urls and self.ids or self.version is not None and self.dataset is None:
241
+ raise ValueError("ids require source/dataset; version requires dataset")
242
+ if not self.task_ids or len(set(self.task_ids)) != len(self.task_ids):
243
+ raise ValueError("task IDs must be nonempty and unique")
244
+ if any(
245
+ not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*(?:/[A-Za-z0-9][A-Za-z0-9_.-]*)?", value)
246
+ for value in self.ids
247
+ ):
248
+ raise ValueError("invalid task ID")
249
+ return self
250
+
251
+ def resolve(self) -> Path:
252
+ if self.source is not None:
253
+ return self.source
254
+ if self._resolved is not None:
255
+ # Revalidate published source bytes, even within one process.
256
+ return materialize_tasks(
257
+ [(self._resolved / "tasks" / task_name(ref), ref) for ref in self._references]
258
+ )
259
+ references = []
260
+ if self.dataset is not None:
261
+ self.dataset, self.version, catalog = dataset_catalog(self.dataset, self.version)
262
+ for selected in self.ids:
263
+ matches = [
264
+ ref
265
+ for ref in catalog
266
+ if selected == f"{ref['org']}/{ref['name']}" or selected == ref["name"]
267
+ ]
268
+ if len(matches) != 1:
269
+ raise ValueError(f"task is missing or ambiguous in dataset: {selected}")
270
+ references.append(matches[0])
271
+ else:
272
+ resolver = importlib.import_module(
273
+ "harbor.registry.client.git_repo"
274
+ ).resolve_repo_source
275
+
276
+ for url in self.urls:
277
+ github_task(url)
278
+ repo = resolver(url)
279
+ references.append(
280
+ {
281
+ "git_url": repo.git_url,
282
+ "git_commit_id": repo.ref or "HEAD",
283
+ "path": repo.subdir or "",
284
+ }
285
+ )
286
+ downloads = download_tasks(references)
287
+ self._resolved = materialize_tasks(downloads)
288
+ self._references = [ref for _, ref in downloads]
289
+ if self.urls:
290
+ self.urls = [
291
+ f"{ref['git_url'].removesuffix('.git')}/tree/{ref['git_commit_id']}/{ref['path']}"
292
+ for ref in self._references
293
+ ]
294
+ return self._resolved
295
+
296
+ @property
297
+ def references(self) -> list[dict[str, str]]:
298
+ return self._references
299
+
300
+
301
+ def printable(text: str) -> str:
302
+ return "".join(char for char in text if char.isprintable() or char in "\n\t")
303
+
304
+
305
+ def preview_task(packet: Path, index: int) -> str:
306
+ references = json.loads(packet.read_text())
307
+ ref = references[index]
308
+ source, _ = download_tasks([ref])[0]
309
+ if (
310
+ source.is_symlink()
311
+ or any(file.is_symlink() for file in source.rglob("*"))
312
+ or any(not (source / name).is_file() for name in ("task.toml", "instruction.md"))
313
+ ):
314
+ raise ValueError("task preview cannot follow symlinks")
315
+ definition = tomllib.loads((source / "task.toml").read_text())
316
+ metadata = definition.get("metadata", {})
317
+ name = task_name(ref)
318
+ return printable(
319
+ f"{name}\nNative Harbor task; selected measurements are checked before evaluation\n"
320
+ f"Language: {metadata.get('language', 'unspecified')}\n"
321
+ f"Repository: {metadata.get('repository_url', 'unspecified')}\n\n"
322
+ + (source / "instruction.md").read_text()
323
+ )
324
+
325
+
326
+ def pick_tasks(dataset: str, version: str | None = None) -> str | None:
327
+ fzf = shutil.which("fzf")
328
+ if fzf is None:
329
+ raise ValueError("tasks pick requires fzf; install fzf or specify tasks in your config")
330
+ if not sys.stderr.isatty():
331
+ raise ValueError("tasks pick requires an interactive terminal")
332
+ name, pin, references = dataset_catalog(dataset, version)
333
+ lines = [f"{index}\t{ref['org']}/{ref['name']}" for index, ref in enumerate(references)]
334
+ with tempfile.TemporaryDirectory(prefix="pluginbench-picker-") as temporary:
335
+ packet = Path(temporary) / "catalog.json"
336
+ packet.write_text(json.dumps(references))
337
+ preview = (
338
+ " ".join(
339
+ shlex.quote(value)
340
+ for value in (sys.executable, "-m", "pluginbench.task_sources", str(packet))
341
+ )
342
+ + " {1}"
343
+ )
344
+ result = subprocess.run(
345
+ [
346
+ fzf,
347
+ "--multi",
348
+ "--layout=reverse",
349
+ "--delimiter=\t",
350
+ "--with-nth=2..",
351
+ "--prompt=Tasks > ",
352
+ "--header=Tab select | Enter YAML | Esc cancel",
353
+ "--preview",
354
+ preview,
355
+ "--preview-window=right,50%,wrap",
356
+ ],
357
+ input="\n".join(lines),
358
+ stdout=subprocess.PIPE,
359
+ text=True,
360
+ check=False,
361
+ )
362
+ if result.returncode in (1, 130):
363
+ return None
364
+ if result.returncode:
365
+ raise ValueError(f"fzf failed with exit code {result.returncode}")
366
+ chosen = result.stdout.splitlines()
367
+ if not chosen:
368
+ return None
369
+ if len(set(chosen)) != len(chosen) or any(line not in lines for line in chosen):
370
+ raise ValueError("fzf returned an invalid task selection")
371
+ selected = [references[int(line.split("\t", 1)[0])] for line in chosen]
372
+ tasks = Tasks(
373
+ dataset=name, version=pin, ids=[f"{ref['org']}/{ref['name']}" for ref in selected]
374
+ )
375
+ # Freeze selected task assets without resolving the dataset twice.
376
+ materialize_tasks(download_tasks(selected))
377
+ return yaml.safe_dump({"tasks": tasks.model_dump(exclude={"source", "urls"})}, sort_keys=False)
378
+
379
+
380
+ def copy_yaml(block: str) -> None:
381
+ candidates = (
382
+ [("pbcopy", [])]
383
+ if sys.platform == "darwin"
384
+ else [
385
+ ("wl-copy", []),
386
+ ("xclip", ["-selection", "clipboard"]),
387
+ ("xsel", ["--clipboard", "--input"]),
388
+ ]
389
+ )
390
+ for name, arguments in candidates:
391
+ executable = shutil.which(name)
392
+ if executable:
393
+ subprocess.run([executable, *arguments], input=block, text=True, check=True)
394
+ return
395
+ raise ValueError("no clipboard command available; the tasks YAML was printed to stdout")
396
+
397
+
398
+ if __name__ == "__main__":
399
+ try:
400
+ print(preview_task(Path(sys.argv[1]), int(sys.argv[2])))
401
+ except (OSError, ValueError, ImportError) as error:
402
+ print(f"Preview unavailable: {error}", file=sys.stderr)
403
+ raise SystemExit(2) from error