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,227 @@
1
+ """Own one run directory: a lock naming its process group, and a stop that signals only that group.
2
+
3
+ This lives outside processes.py on purpose: processes.py is part of the vanilla launcher hash,
4
+ so editing it would change every vanilla cache key.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fcntl
10
+ import json
11
+ import os
12
+ import signal
13
+ import subprocess
14
+ import threading
15
+ import time
16
+ from collections.abc import Iterator
17
+ from contextlib import contextmanager
18
+ from dataclasses import dataclass
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path
21
+ from types import FrameType
22
+
23
+ LOCK_NAME = "run.lock"
24
+ _PROBE_GRACE = 0.5
25
+
26
+
27
+ class RunMoved(ValueError):
28
+ """The run directory is no longer at the path its lock recorded."""
29
+
30
+
31
+ def process_start(pid: int) -> str:
32
+ """The kernel's start time for `pid`, so a reused PID never matches a recorded owner."""
33
+ if Path("/proc/self/stat").is_file():
34
+ try:
35
+ stat = Path(f"/proc/{pid}/stat").read_text()
36
+ except FileNotFoundError as exc:
37
+ raise ProcessLookupError(pid) from exc
38
+ # Field 22 is starttime. Split after the last ")" because the command name may hold
39
+ # spaces or parentheses; the first field after it is field 3.
40
+ return stat.rsplit(")", 1)[1].split()[19]
41
+ result = subprocess.run(
42
+ ["ps", "-o", "lstart=", "-p", str(pid)], capture_output=True, text=True, check=False
43
+ )
44
+ if result.returncode != 0 or not result.stdout.strip():
45
+ raise ProcessLookupError(pid)
46
+ return result.stdout.strip()
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class RunLock:
51
+ root: Path
52
+ fd: int
53
+
54
+ def assert_in_place(self) -> None:
55
+ """Raise RunMoved when the root path no longer leads to the lock this process holds."""
56
+ held = os.fstat(self.fd)
57
+ try:
58
+ current = os.stat(self.root / LOCK_NAME)
59
+ except FileNotFoundError:
60
+ current = None
61
+ if current is None or (current.st_dev, current.st_ino) != (held.st_dev, held.st_ino):
62
+ raise RunMoved(f"run directory moved away from {self.root}; no further work starts")
63
+
64
+
65
+ # Roots whose lock this process holds now. The owner record can lag a handover, so it cannot
66
+ # say whether this process still holds a lock.
67
+ _held: set[Path] = set()
68
+
69
+
70
+ def held_here(root: Path) -> bool:
71
+ """Whether this process holds root/run.lock right now."""
72
+ return root.resolve() in _held
73
+
74
+
75
+ def _owner(fd: int) -> dict[str, object] | None:
76
+ text = os.pread(fd, 1 << 16, 0).decode()
77
+ if not text.strip():
78
+ return None
79
+ value = json.loads(text)
80
+ if not isinstance(value, dict):
81
+ raise ValueError("run.lock must hold a JSON object")
82
+ return value
83
+
84
+
85
+ def _interrupt(signum: int, frame: FrameType | None) -> None:
86
+ # Reuse the interrupt path: no new attempts start and saved work is checkpointed.
87
+ raise KeyboardInterrupt("stopped by SIGTERM")
88
+
89
+
90
+ @contextmanager
91
+ def deferred_stop() -> Iterator[None]:
92
+ """Hold SIGTERM and SIGINT until the block ends, then deliver them in arrival order.
93
+
94
+ For writes that must land together: `stop` and Ctrl-C otherwise interrupt between any two
95
+ bytecodes. Keep the block to those writes. Only the main thread runs signal handlers, so
96
+ elsewhere nothing can interrupt the block and it runs as is.
97
+ """
98
+ if threading.current_thread() is not threading.main_thread():
99
+ yield
100
+ return
101
+ pending: list[int] = []
102
+
103
+ def defer(signum: int, frame: FrameType | None) -> None:
104
+ pending.append(signum)
105
+
106
+ # An ignored signal stays ignored. A handler installed outside Python cannot be put back.
107
+ previous = {
108
+ item: handler
109
+ for item in (signal.SIGTERM, signal.SIGINT)
110
+ if (handler := signal.getsignal(item)) not in (None, signal.SIG_IGN)
111
+ }
112
+ try:
113
+ for item in previous:
114
+ signal.signal(item, defer)
115
+ yield
116
+ finally:
117
+ failure: BaseException | None = None
118
+ while True:
119
+ try:
120
+ for item, handler in previous.items():
121
+ signal.signal(item, handler)
122
+ break
123
+ except BaseException as exc:
124
+ # A signal that arrives now runs a handler already restored, which may raise
125
+ # anywhere in the loop. Keep the first error and restore every handler again.
126
+ failure = failure or exc
127
+ if failure is not None:
128
+ raise failure
129
+ # In arrival order: a handler that returns must not swallow a later stop.
130
+ for signum in pending:
131
+ signal.raise_signal(signum)
132
+
133
+
134
+ @contextmanager
135
+ def hold(root: Path) -> Iterator[RunLock]:
136
+ """Own `root` while the block runs; SIGTERM becomes KeyboardInterrupt in this process.
137
+
138
+ The flock is released when the process exits, even by SIGKILL, so a lock that can be
139
+ acquired always belongs to a dead owner. The file stays behind and keeps the last root.
140
+ """
141
+ root = root.resolve()
142
+ fd = os.open(root / LOCK_NAME, os.O_RDWR | os.O_CREAT, 0o644)
143
+ try:
144
+ # `status` probes ownership with a brief shared flock; wait that out, not a real owner.
145
+ deadline = time.monotonic() + _PROBE_GRACE
146
+ while True:
147
+ try:
148
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
149
+ break
150
+ except BlockingIOError as exc:
151
+ if time.monotonic() > deadline:
152
+ raise ValueError(
153
+ f"run is already active: {root}; stop it with `pluginbench stop {root}`"
154
+ ) from exc
155
+ time.sleep(0.02)
156
+ previous = _owner(fd)
157
+ if previous is not None and previous.get("root") != str(root):
158
+ raise ValueError(
159
+ f"run was at {previous.get('root')} and is now at {root}; if you moved a stopped "
160
+ f"run on purpose, delete {root / LOCK_NAME} and run it again"
161
+ )
162
+ owner = {
163
+ "pid": os.getpid(),
164
+ "pgid": os.getpgrp(),
165
+ "process_start": process_start(os.getpid()),
166
+ "started_at": datetime.now(UTC).isoformat(),
167
+ "root": str(root),
168
+ }
169
+ os.ftruncate(fd, 0)
170
+ os.pwrite(fd, (json.dumps(owner, indent=2) + "\n").encode(), 0)
171
+ os.fsync(fd)
172
+ previous_handler = signal.signal(signal.SIGTERM, _interrupt)
173
+ try:
174
+ _held.add(root)
175
+ yield RunLock(root, fd)
176
+ finally:
177
+ _held.discard(root)
178
+ signal.signal(signal.SIGTERM, previous_handler)
179
+ finally:
180
+ os.close(fd)
181
+
182
+
183
+ def owner(root: Path) -> dict[str, object] | None:
184
+ """The recorded owner while a live process holds root/run.lock; otherwise None."""
185
+ path = root.resolve() / LOCK_NAME
186
+ if not path.is_file():
187
+ return None
188
+ fd = os.open(path, os.O_RDONLY)
189
+ try:
190
+ try:
191
+ fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
192
+ except BlockingIOError:
193
+ # Held: the owner may not have written its record yet.
194
+ return _owner(fd) or {}
195
+ return None
196
+ finally:
197
+ os.close(fd)
198
+
199
+
200
+ def stop(root: Path) -> dict[str, object] | None:
201
+ """SIGTERM the process group recorded in root/run.lock; None when no live run owns it.
202
+
203
+ Nothing is signalled unless the lock is held and its recorded process still has the
204
+ recorded start time and group. This command's own group is never signalled.
205
+ """
206
+ if not (root.resolve() / LOCK_NAME).is_file():
207
+ raise ValueError(f"no run lock in {root}; it never started here")
208
+ current = owner(root)
209
+ if current is None:
210
+ return None
211
+ if not current:
212
+ raise ValueError("run.lock is held but names no owner yet; try again")
213
+ pid, pgid = current.get("pid"), current.get("pgid")
214
+ if type(pid) is not int or type(pgid) is not int:
215
+ raise ValueError("run.lock owner must record integer pid and pgid")
216
+ try:
217
+ matches = process_start(pid) == current.get("process_start") and os.getpgid(pid) == pgid
218
+ except ProcessLookupError:
219
+ matches = False
220
+ if not matches:
221
+ raise ValueError(
222
+ f"run.lock owner {pid} no longer matches its recorded process; nothing signalled"
223
+ )
224
+ if pgid == os.getpgrp():
225
+ raise ValueError("refusing to signal this command's own process group")
226
+ os.killpg(pgid, signal.SIGTERM)
227
+ return current