iclayout-bench 0.4.0a1__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.
- benchmarking/__init__.py +1 -0
- benchmarking/_build.json +1 -0
- benchmarking/analysis.py +185 -0
- benchmarking/analyze.py +29 -0
- benchmarking/bundles.py +89 -0
- benchmarking/client.py +289 -0
- benchmarking/dataset.py +136 -0
- benchmarking/dataset_index.py +133 -0
- benchmarking/engine/__init__.py +1 -0
- benchmarking/engine/benchmark_feedback.py +60 -0
- benchmarking/engine/cli.py +129 -0
- benchmarking/engine/docker.py +122 -0
- benchmarking/engine/environment.py +42 -0
- benchmarking/engine/evaluate.py +267 -0
- benchmarking/engine/execution.py +183 -0
- benchmarking/engine/geometry.py +123 -0
- benchmarking/engine/geometry_runner.py +115 -0
- benchmarking/engine/hbt.py +1718 -0
- benchmarking/engine/hbt_runner.py +63 -0
- benchmarking/engine/identity.py +19 -0
- benchmarking/engine/inference.py +675 -0
- benchmarking/engine/klayout.py +195 -0
- benchmarking/engine/klayout_runner.py +265 -0
- benchmarking/engine/kpex.py +93 -0
- benchmarking/engine/kpex_runner.py +150 -0
- benchmarking/engine/layout_image.py +42 -0
- benchmarking/engine/magic.py +204 -0
- benchmarking/engine/magic_netlist.py +80 -0
- benchmarking/engine/magic_ports.py +50 -0
- benchmarking/engine/messages.py +155 -0
- benchmarking/engine/model_config.py +77 -0
- benchmarking/engine/ngspice.py +159 -0
- benchmarking/engine/pdk_installation.py +68 -0
- benchmarking/engine/pdk_probe.py +42 -0
- benchmarking/engine/pdk_resources.py +151 -0
- benchmarking/engine/preparation.py +48 -0
- benchmarking/engine/prepare_support.py +111 -0
- benchmarking/engine/preview.py +284 -0
- benchmarking/engine/process_check.py +12 -0
- benchmarking/engine/qualification.py +285 -0
- benchmarking/engine/recorder.py +187 -0
- benchmarking/engine/recording.py +20 -0
- benchmarking/engine/resource_cache.py +34 -0
- benchmarking/engine/runtime.py +61 -0
- benchmarking/engine/session.py +611 -0
- benchmarking/engine/snapshot.py +41 -0
- benchmarking/engine/source.py +29 -0
- benchmarking/engine/submit.py +12 -0
- benchmarking/engine/toolchains.py +88 -0
- benchmarking/engine/workspace.py +101 -0
- benchmarking/evaluation.py +421 -0
- benchmarking/files.py +126 -0
- benchmarking/harnesses.py +89 -0
- benchmarking/layout_preview.py +104 -0
- benchmarking/model_configs.yaml +2 -0
- benchmarking/observe.py +72 -0
- benchmarking/participants/__init__.py +1 -0
- benchmarking/participants/archive.py +57 -0
- benchmarking/participants/bridge.py +176 -0
- benchmarking/participants/config.py +202 -0
- benchmarking/participants/dsh.py +102 -0
- benchmarking/participants/process.py +31 -0
- benchmarking/participants/recovery.py +106 -0
- benchmarking/participants/results.py +285 -0
- benchmarking/participants/runner.py +402 -0
- benchmarking/participants/scheme.py +85 -0
- benchmarking/participants/storage.py +20 -0
- benchmarking/plotting.py +149 -0
- benchmarking/protocol.py +42 -0
- benchmarking/results/__init__.py +5 -0
- benchmarking/results/__main__.py +81 -0
- benchmarking/results/geometry.py +142 -0
- benchmarking/results/presentation.py +333 -0
- benchmarking/results/schema.py +105 -0
- benchmarking/results/store.py +788 -0
- benchmarking/run.py +348 -0
- benchmarking/scoring.py +201 -0
- benchmarking/service/__init__.py +1 -0
- benchmarking/service/__main__.py +45 -0
- benchmarking/service/server.py +545 -0
- benchmarking/tasks.py +307 -0
- benchmarking/transfer.py +256 -0
- benchmarking/version.py +17 -0
- iclayout_bench-0.4.0a1.dist-info/METADATA +100 -0
- iclayout_bench-0.4.0a1.dist-info/RECORD +88 -0
- iclayout_bench-0.4.0a1.dist-info/WHEEL +5 -0
- iclayout_bench-0.4.0a1.dist-info/licenses/LICENSE +21 -0
- iclayout_bench-0.4.0a1.dist-info/top_level.txt +1 -0
benchmarking/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ICLayout-Bench: HTTP client, observation, public contracts and analysis."""
|
benchmarking/_build.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version": "0.4.0a1", "commit": "c0f67225a7b56242275239a65ed70507c23cd7a3"}
|
benchmarking/analysis.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Analysis of service results; no evaluator or private-package dependency."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .files import Asset, atomic_write
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _csv(path, columns, rows):
|
|
11
|
+
with Path(path).open("w", newline="", encoding="utf-8") as stream:
|
|
12
|
+
writer = csv.DictWriter(stream, fieldnames=columns)
|
|
13
|
+
writer.writeheader()
|
|
14
|
+
writer.writerows({key: row.get(key) for key in columns} for row in rows)
|
|
15
|
+
Path(path).chmod(0o600)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def export_session_result(result, destination):
|
|
19
|
+
"""Export service-reported data, preserving its trust label and missing values.
|
|
20
|
+
|
|
21
|
+
This is not local cryptographic verification of the private judge's evidence.
|
|
22
|
+
The complete response and its digest accompany plotting tables.
|
|
23
|
+
"""
|
|
24
|
+
from .protocol import PROTOCOL, json_bytes
|
|
25
|
+
|
|
26
|
+
if result.get("protocol") != PROTOCOL or result.get("state") not in {
|
|
27
|
+
"complete",
|
|
28
|
+
"error",
|
|
29
|
+
}:
|
|
30
|
+
raise ValueError("Expected a terminal layout-http result")
|
|
31
|
+
output = Path(destination)
|
|
32
|
+
output.mkdir(parents=True, exist_ok=False)
|
|
33
|
+
raw = json_bytes(result)
|
|
34
|
+
atomic_write(output / "result.json", raw)
|
|
35
|
+
receipt = result.get("submission") or {}
|
|
36
|
+
score = result.get("score") or {}
|
|
37
|
+
row = {
|
|
38
|
+
key: result.get(key)
|
|
39
|
+
for key in (
|
|
40
|
+
"session_id",
|
|
41
|
+
"task_id",
|
|
42
|
+
"task_sha256",
|
|
43
|
+
"verification_level",
|
|
44
|
+
"outcome",
|
|
45
|
+
"task_success",
|
|
46
|
+
"failure_reason",
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
row.update(
|
|
50
|
+
candidate_sha256=receipt.get("candidate_sha256"),
|
|
51
|
+
score=score.get("value"),
|
|
52
|
+
**result["condition"],
|
|
53
|
+
**result["usage"],
|
|
54
|
+
)
|
|
55
|
+
for key in ("tool_identity", "limits", "provenance"):
|
|
56
|
+
row[key] = json.dumps(result[key], sort_keys=True)
|
|
57
|
+
_csv(output / "summary.csv", tuple(row), [row])
|
|
58
|
+
metrics = []
|
|
59
|
+
for name, metric in result.get("metrics", {}).items():
|
|
60
|
+
metrics.append(
|
|
61
|
+
{
|
|
62
|
+
"session_id": result["session_id"],
|
|
63
|
+
"metric": name,
|
|
64
|
+
"value": metric.get("value"),
|
|
65
|
+
"unit": metric.get("unit"),
|
|
66
|
+
"status": metric.get("status"),
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
_csv(
|
|
70
|
+
output / "metrics.csv",
|
|
71
|
+
("session_id", "metric", "value", "unit", "status"),
|
|
72
|
+
metrics,
|
|
73
|
+
)
|
|
74
|
+
atomic_write(
|
|
75
|
+
output / "manifest.json",
|
|
76
|
+
json_bytes(
|
|
77
|
+
{
|
|
78
|
+
"source_sha256": Asset(raw, "json").sha256,
|
|
79
|
+
"verification": "service_reported",
|
|
80
|
+
"missing_csv_values": "empty",
|
|
81
|
+
"files": {
|
|
82
|
+
name: Asset((output / name).read_bytes(), "binary").identity()
|
|
83
|
+
for name in ("result.json", "summary.csv", "metrics.csv")
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
return output
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def export_results(results, destination, *, plots=False):
|
|
92
|
+
"""Compare like conditions; incomplete/error results never become zero scores.
|
|
93
|
+
|
|
94
|
+
This consumes disclosed service results, not private evaluator artifacts.
|
|
95
|
+
Verification labels are retained, not independently upgraded by analysis.
|
|
96
|
+
"""
|
|
97
|
+
from collections import defaultdict
|
|
98
|
+
from statistics import mean, stdev
|
|
99
|
+
|
|
100
|
+
from .protocol import PROTOCOL, json_bytes
|
|
101
|
+
|
|
102
|
+
output = Path(destination)
|
|
103
|
+
output.mkdir(parents=True, exist_ok=False, mode=0o700)
|
|
104
|
+
groups = defaultdict(list)
|
|
105
|
+
rows = []
|
|
106
|
+
seen = set()
|
|
107
|
+
for result in results:
|
|
108
|
+
if result.get("protocol") != PROTOCOL or result.get("test_only"):
|
|
109
|
+
raise ValueError(
|
|
110
|
+
"Only real protocol results can enter measurement analysis"
|
|
111
|
+
)
|
|
112
|
+
sid = result["session_id"]
|
|
113
|
+
if sid in seen:
|
|
114
|
+
raise ValueError("Duplicate session")
|
|
115
|
+
seen.add(sid)
|
|
116
|
+
binding = {
|
|
117
|
+
key: result[key]
|
|
118
|
+
for key in ("condition", "verification_level", "tool_identity", "limits")
|
|
119
|
+
}
|
|
120
|
+
cohort = Asset(json_bytes(binding), "json").sha256
|
|
121
|
+
value = (result.get("score") or {}).get("value")
|
|
122
|
+
if result.get("outcome") == "error" or result.get("state") not in {
|
|
123
|
+
"complete",
|
|
124
|
+
"error",
|
|
125
|
+
}:
|
|
126
|
+
value = None
|
|
127
|
+
row = {
|
|
128
|
+
"cohort": cohort,
|
|
129
|
+
"session_id": sid,
|
|
130
|
+
"task_id": result["task_id"],
|
|
131
|
+
"task_sha256": result["task_sha256"],
|
|
132
|
+
"state": result["state"],
|
|
133
|
+
"outcome": result.get("outcome"),
|
|
134
|
+
"score": value,
|
|
135
|
+
"verification_level": result["verification_level"],
|
|
136
|
+
**result["condition"],
|
|
137
|
+
**result["usage"],
|
|
138
|
+
}
|
|
139
|
+
rows.append(row)
|
|
140
|
+
groups[(cohort, result["task_id"], result["task_sha256"])].append(row)
|
|
141
|
+
atomic_write(output / "results.json", json_bytes(results))
|
|
142
|
+
if rows:
|
|
143
|
+
_csv(output / "runs.csv", tuple(rows[0]), rows)
|
|
144
|
+
summaries = []
|
|
145
|
+
for (cohort, task, sha), group in sorted(groups.items()):
|
|
146
|
+
values = [row["score"] for row in group if row["score"] is not None]
|
|
147
|
+
summaries.append(
|
|
148
|
+
{
|
|
149
|
+
"cohort": cohort,
|
|
150
|
+
"task_id": task,
|
|
151
|
+
"task_sha256": sha,
|
|
152
|
+
"trials": len(group),
|
|
153
|
+
"measured": len(values),
|
|
154
|
+
"unknown": len(group) - len(values),
|
|
155
|
+
"mean": mean(values) if values else None,
|
|
156
|
+
"sample_stddev": stdev(values) if len(values) > 1 else None,
|
|
157
|
+
"pass": sum(row["outcome"] == "pass" for row in group),
|
|
158
|
+
"fail": sum(row["outcome"] == "fail" for row in group),
|
|
159
|
+
"no_submission": sum(
|
|
160
|
+
row["outcome"] == "no_submission" for row in group
|
|
161
|
+
),
|
|
162
|
+
"infrastructure_error": sum(row["outcome"] == "error" for row in group),
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
if summaries:
|
|
166
|
+
_csv(output / "tasks.csv", tuple(summaries[0]), summaries)
|
|
167
|
+
if plots and summaries:
|
|
168
|
+
from .plotting import render_service_results
|
|
169
|
+
|
|
170
|
+
render_service_results(rows, output)
|
|
171
|
+
atomic_write(
|
|
172
|
+
output / "manifest.json",
|
|
173
|
+
json_bytes(
|
|
174
|
+
{
|
|
175
|
+
"verification": "service_reported",
|
|
176
|
+
"missing_csv_values": "empty",
|
|
177
|
+
"files": {
|
|
178
|
+
p.name: Asset(p.read_bytes(), "binary").identity()
|
|
179
|
+
for p in sorted(output.iterdir())
|
|
180
|
+
if p.is_file()
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
return output
|
benchmarking/analyze.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Export disclosed HTTP results or an operator-verified public rerun release."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .analysis import export_results
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
12
|
+
parser.add_argument("inputs", nargs="+", type=Path)
|
|
13
|
+
parser.add_argument("--output", required=True, type=Path)
|
|
14
|
+
parser.add_argument("--plots", action="store_true")
|
|
15
|
+
args = parser.parse_args()
|
|
16
|
+
results = []
|
|
17
|
+
for path in args.inputs:
|
|
18
|
+
data = json.loads(path.read_bytes())
|
|
19
|
+
if isinstance(data, dict) and "results" in data:
|
|
20
|
+
if data.get("dataset") != "public_development":
|
|
21
|
+
raise ValueError("Hidden results require operator disclosure export")
|
|
22
|
+
results.extend(data["results"])
|
|
23
|
+
else:
|
|
24
|
+
results.extend(data if isinstance(data, list) else [data])
|
|
25
|
+
print(export_results(results, args.output, plots=args.plots))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|
benchmarking/bundles.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Portable file bundles and local bindings to installed tool resources."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .files import Asset, ReadOnlyMount, keys, read_file, relative
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Bundle:
|
|
12
|
+
files: tuple[tuple[str, Asset | ReadOnlyMount], ...]
|
|
13
|
+
manifest: Asset
|
|
14
|
+
paths: tuple[tuple[str, Path], ...] = ()
|
|
15
|
+
|
|
16
|
+
def mounted_files(self) -> dict[str, Asset | ReadOnlyMount]:
|
|
17
|
+
return {f"support/{name}": asset for name, asset in self.files}
|
|
18
|
+
|
|
19
|
+
def evidence(self) -> dict[str, Asset]:
|
|
20
|
+
if any(isinstance(asset, ReadOnlyMount) for _, asset in self.files):
|
|
21
|
+
return {"support_manifest": self.manifest}
|
|
22
|
+
return {"support_manifest": self.manifest,
|
|
23
|
+
**{f"support:{name}": asset for name, asset in self.files}}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_bundle(root: Path) -> Bundle:
|
|
27
|
+
root = root.absolute()
|
|
28
|
+
if (root / "resource.json").is_file():
|
|
29
|
+
return load_resources(root)
|
|
30
|
+
manifest = Asset(read_file(root, "manifest.json"), "json")
|
|
31
|
+
data = json.loads(manifest.content)
|
|
32
|
+
keys(data, {"files", "provenance"}, set(), "support bundle")
|
|
33
|
+
if not isinstance(data["files"], dict) or not data["files"]:
|
|
34
|
+
raise ValueError("Support bundle needs a nonempty file list")
|
|
35
|
+
files = []
|
|
36
|
+
for name, identity in data["files"].items():
|
|
37
|
+
relative(name, "support path")
|
|
38
|
+
if name == "manifest.json":
|
|
39
|
+
raise ValueError("manifest.json is reserved")
|
|
40
|
+
keys(identity, {"sha256", "format", "bytes"}, set(), "support identity")
|
|
41
|
+
asset = Asset(read_file(root, name), identity["format"])
|
|
42
|
+
if asset.identity() != identity:
|
|
43
|
+
raise ValueError(f"Support checksum mismatch: {name}")
|
|
44
|
+
files.append((name, asset))
|
|
45
|
+
actual = set()
|
|
46
|
+
for path in root.rglob("*"):
|
|
47
|
+
if path.is_symlink():
|
|
48
|
+
raise ValueError(f"Symlink in support bundle: {path}")
|
|
49
|
+
if path.is_file():
|
|
50
|
+
actual.add(path.relative_to(root).as_posix())
|
|
51
|
+
if actual != set(data["files"]) | {"manifest.json"}:
|
|
52
|
+
raise ValueError("Support bundle contains undeclared files")
|
|
53
|
+
return Bundle(tuple(files), manifest, tuple((name, root / name) for name, _ in files))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def publish_bundle(files: dict[str, Asset], provenance: dict, destination: Path) -> Bundle:
|
|
57
|
+
"""Publish new preparation output, never overwrite an existing bundle."""
|
|
58
|
+
destination = destination.absolute()
|
|
59
|
+
if destination.exists() or destination.is_symlink():
|
|
60
|
+
raise FileExistsError(f"Bundle destination exists: {destination}")
|
|
61
|
+
for name in files:
|
|
62
|
+
relative(name, "support path")
|
|
63
|
+
if name == "manifest.json":
|
|
64
|
+
raise ValueError("manifest.json is reserved")
|
|
65
|
+
manifest = {"files": {k: a.identity() for k, a in sorted(files.items())},
|
|
66
|
+
"provenance": provenance}
|
|
67
|
+
raw = json.dumps(manifest, sort_keys=True, indent=2, allow_nan=False).encode() + b"\n"
|
|
68
|
+
destination.mkdir(parents=True)
|
|
69
|
+
for name, asset in {**files, "manifest.json": Asset(raw, "json")}.items():
|
|
70
|
+
path = destination / name
|
|
71
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
path.write_bytes(asset.content)
|
|
73
|
+
path.chmod(0o444)
|
|
74
|
+
return load_bundle(destination)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_resources(root: Path) -> Bundle:
|
|
78
|
+
"""Load mount bindings without traversing or copying installed PDKs."""
|
|
79
|
+
root = root.resolve()
|
|
80
|
+
data = json.loads((root / "resource.json").read_text())
|
|
81
|
+
if "root" in data:
|
|
82
|
+
return load_resources(Path(data["root"]))
|
|
83
|
+
manifest = Asset((json.dumps({"provenance": data["provenance"]}, sort_keys=True) + "\n").encode(), "json")
|
|
84
|
+
paths = {name: Path(path) for name, path in data["mounts"].items()}
|
|
85
|
+
paths.update({p.relative_to(root).as_posix(): p for p in root.rglob("*")
|
|
86
|
+
if p.is_file() and p.name != "resource.json"})
|
|
87
|
+
files = tuple((relative(name, "resource target"), ReadOnlyMount(path, manifest))
|
|
88
|
+
for name, path in sorted(paths.items()))
|
|
89
|
+
return Bundle(files, manifest, tuple(sorted(paths.items())))
|
benchmarking/client.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Generic layout-http client; no Docker, model SDK or Private dependency."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import http.client
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
from urllib.parse import urlencode, urlsplit
|
|
14
|
+
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
|
15
|
+
|
|
16
|
+
from .protocol import (
|
|
17
|
+
PROTOCOL,
|
|
18
|
+
SESSION_STARTUP_RESPONSE_GRACE_SECONDS,
|
|
19
|
+
SESSION_STARTUP_TIMEOUT_SECONDS,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
23
|
+
_KEY = re.compile(r"[A-Za-z0-9_.-]{1,128}\Z")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _reject_constant(value):
|
|
27
|
+
raise ValueError("Non-finite JSON number")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ClientError(Exception):
|
|
31
|
+
"""A structured service error or a locally rejected response."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, code, message, *, status=None, retryable=False, retry_after=None):
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.code, self.status, self.retryable = code, status, retryable
|
|
36
|
+
self.retry_after = retry_after
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
40
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
41
|
+
return None # Never forward a session credential to a redirected endpoint.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _identifier(value):
|
|
45
|
+
if not isinstance(value, str) or not _KEY.fullmatch(value):
|
|
46
|
+
raise ValueError("Invalid opaque identifier or idempotency key")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Client:
|
|
51
|
+
"""Explicit retries: retain and replay the same key after uncertain delivery.
|
|
52
|
+
|
|
53
|
+
Automatic bounded same-key retries require an explicit retry_policy.
|
|
54
|
+
Without one, the client performs exactly one transport attempt.
|
|
55
|
+
Construct a new instance with the returned session token after creation.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, endpoint, token, *, timeout=30, max_response_bytes=MAX_RESPONSE_BYTES,
|
|
59
|
+
retry_policy=None):
|
|
60
|
+
parsed = urlsplit(endpoint)
|
|
61
|
+
if (parsed.scheme not in {"http", "https"} or not parsed.hostname
|
|
62
|
+
or parsed.username or parsed.password or parsed.query or parsed.fragment):
|
|
63
|
+
raise ValueError("Expected a service URL without credentials, query or fragment")
|
|
64
|
+
if parsed.scheme == "http" and parsed.hostname not in {"127.0.0.1", "localhost", "::1"}:
|
|
65
|
+
raise ValueError("Non-loopback service endpoints require HTTPS")
|
|
66
|
+
if not isinstance(token, str) or not token or any(ord(c) < 33 or ord(c) > 126 for c in token):
|
|
67
|
+
raise ValueError("Expected a nonempty bearer token")
|
|
68
|
+
if timeout <= 0 or max_response_bytes <= 0:
|
|
69
|
+
raise ValueError("Timeout and response limit must be positive")
|
|
70
|
+
from .participants.recovery import policy
|
|
71
|
+
self.retry_policy = policy(retry_policy)
|
|
72
|
+
self.endpoint = endpoint.rstrip("/")
|
|
73
|
+
self.token = token
|
|
74
|
+
self.timeout = timeout
|
|
75
|
+
self.max_response_bytes = max_response_bytes
|
|
76
|
+
self._opener = build_opener(ProxyHandler({}), _NoRedirect())
|
|
77
|
+
|
|
78
|
+
def request(self, method, path, body=None, *, key=None):
|
|
79
|
+
# One owner for service transport retries. Every replay retains body/key.
|
|
80
|
+
settings = self.retry_policy
|
|
81
|
+
for attempt in range(settings["http_attempts"]):
|
|
82
|
+
try:
|
|
83
|
+
return self._request(method, path, body, key=key)
|
|
84
|
+
except ClientError as error:
|
|
85
|
+
recoverable = error.code == "transport_error" or (error.status == 503 and error.retryable)
|
|
86
|
+
if not recoverable or attempt + 1 == settings["http_attempts"]:
|
|
87
|
+
raise
|
|
88
|
+
delay = min(settings["max_backoff_seconds"], settings["backoff_seconds"] * 2 ** attempt)
|
|
89
|
+
if error.retry_after is not None:
|
|
90
|
+
if error.retry_after > settings["max_backoff_seconds"]:
|
|
91
|
+
raise
|
|
92
|
+
delay = max(delay, error.retry_after)
|
|
93
|
+
time.sleep(delay)
|
|
94
|
+
|
|
95
|
+
def _request(self, method, path, body=None, *, key=None):
|
|
96
|
+
if not path.startswith("/sessions") or path.startswith("//") or "#" in path:
|
|
97
|
+
raise ValueError("Expected a /sessions service path")
|
|
98
|
+
if method not in {"GET", "POST"}:
|
|
99
|
+
raise ValueError("Only GET and POST are supported")
|
|
100
|
+
headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/json"}
|
|
101
|
+
data = None
|
|
102
|
+
if method == "POST":
|
|
103
|
+
headers["Idempotency-Key"] = _identifier(key)
|
|
104
|
+
headers["Content-Type"] = "application/json"
|
|
105
|
+
data = json.dumps(body, allow_nan=False, separators=(",", ":")).encode()
|
|
106
|
+
elif body is not None or key is not None:
|
|
107
|
+
raise ValueError("GET does not accept a body or idempotency key")
|
|
108
|
+
req = Request(self.endpoint + path, data=data, headers=headers, method=method)
|
|
109
|
+
try:
|
|
110
|
+
try:
|
|
111
|
+
timeout = self.timeout
|
|
112
|
+
if method == "POST" and path == "/sessions":
|
|
113
|
+
timeout = max(timeout, SESSION_STARTUP_TIMEOUT_SECONDS + SESSION_STARTUP_RESPONSE_GRACE_SECONDS)
|
|
114
|
+
response = self._opener.open(req, timeout=timeout)
|
|
115
|
+
except HTTPError as error:
|
|
116
|
+
response = error
|
|
117
|
+
with response:
|
|
118
|
+
status = response.code
|
|
119
|
+
retry_after = response.headers.get("Retry-After") if hasattr(response, "headers") else None
|
|
120
|
+
retry_after = int(retry_after) if retry_after and retry_after.isdigit() else None
|
|
121
|
+
raw = response.read(self.max_response_bytes + 1)
|
|
122
|
+
except (URLError, TimeoutError, OSError, http.client.HTTPException) as error:
|
|
123
|
+
raise ClientError("transport_error", "Delivery unknown; query status or replay the same key",
|
|
124
|
+
retryable=True) from error
|
|
125
|
+
if len(raw) > self.max_response_bytes:
|
|
126
|
+
raise ClientError("invalid_response", "Service response exceeds client limit", status=status)
|
|
127
|
+
try:
|
|
128
|
+
result = json.loads(raw, parse_constant=_reject_constant)
|
|
129
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
130
|
+
raise ClientError("invalid_response", "Service returned invalid JSON", status=status) from error
|
|
131
|
+
if not isinstance(result, dict) or result.get("protocol") != PROTOCOL:
|
|
132
|
+
raise ClientError("incompatible_protocol", "Unsupported service response", status=status)
|
|
133
|
+
if not 200 <= status < 300:
|
|
134
|
+
detail = result.get("error", {})
|
|
135
|
+
if not isinstance(detail, dict):
|
|
136
|
+
detail = {}
|
|
137
|
+
raise ClientError(detail.get("code", "invalid_response"),
|
|
138
|
+
str(detail.get("message", "Service request failed")), status=status,
|
|
139
|
+
retryable=detail.get("retryable") is True, retry_after=retry_after)
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
def create(self, task_id, condition, *, key):
|
|
143
|
+
return self.request("POST", "/sessions", {"task_id": task_id, "condition": condition}, key=key)
|
|
144
|
+
|
|
145
|
+
def session(self, session_id, operation="", body=None, *, key=None):
|
|
146
|
+
path = f"/sessions/{_identifier(session_id)}"
|
|
147
|
+
if operation:
|
|
148
|
+
path += "/" + operation
|
|
149
|
+
return self.request("POST" if key is not None else "GET", path, body, key=key)
|
|
150
|
+
|
|
151
|
+
def read(self, session_id, path):
|
|
152
|
+
result = self.session(session_id, "file?" + urlencode({"path": path}))
|
|
153
|
+
try:
|
|
154
|
+
content = base64.b64decode(result["content_base64"], validate=True)
|
|
155
|
+
if len(content) != result["size_bytes"] or hashlib.sha256(content).hexdigest() != result["sha256"]:
|
|
156
|
+
raise ValueError("Content digest mismatch")
|
|
157
|
+
except (KeyError, ValueError, TypeError) as error:
|
|
158
|
+
raise ClientError("invalid_response", "File integrity check failed") from error
|
|
159
|
+
return content
|
|
160
|
+
|
|
161
|
+
def write(self, session_id, path, content, *, key):
|
|
162
|
+
return self.session(session_id, "files", {
|
|
163
|
+
"path": path, "content_base64": base64.b64encode(content).decode("ascii")}, key=key)
|
|
164
|
+
|
|
165
|
+
def execute(self, session_id, command, timeout_seconds, *, key):
|
|
166
|
+
return self.session(session_id, "executions", {
|
|
167
|
+
"command": command, "timeout_seconds": timeout_seconds}, key=key)
|
|
168
|
+
|
|
169
|
+
def poll(self, session_id, execution_id, *, offset=0):
|
|
170
|
+
if type(offset) is not int or offset < 0:
|
|
171
|
+
raise ValueError("Offset must be a nonnegative integer")
|
|
172
|
+
return self.session(session_id, f"executions/{_identifier(execution_id)}?offset={offset}")
|
|
173
|
+
|
|
174
|
+
def check(self, session_id, timeout_seconds=None, *, key):
|
|
175
|
+
"""Start a frozen-candidate check through the normal replayable execution API."""
|
|
176
|
+
status = self.session(session_id)
|
|
177
|
+
if "process-feedback" not in status.get("capabilities", []):
|
|
178
|
+
raise ValueError("Service does not advertise process-feedback")
|
|
179
|
+
seconds = status["remaining_seconds"] if timeout_seconds is None else timeout_seconds
|
|
180
|
+
return self.execute(session_id, "python -I /protocol/process_check.py", seconds, key=key)
|
|
181
|
+
|
|
182
|
+
def submit(self, session_id, path, *, key):
|
|
183
|
+
return self.session(session_id, "submissions", {"path": path}, key=key)
|
|
184
|
+
|
|
185
|
+
def close(self, session_id, *, key):
|
|
186
|
+
return self.session(session_id, "close", {}, key=key)
|
|
187
|
+
|
|
188
|
+
def observations(self, session_id, *, offset=0):
|
|
189
|
+
if type(offset) is not int or offset < 0:
|
|
190
|
+
raise ValueError("Offset must be a nonnegative integer")
|
|
191
|
+
return self.session(session_id, "observations?" + urlencode({"offset": offset}))
|
|
192
|
+
|
|
193
|
+
def result(self, session_id):
|
|
194
|
+
return self.session(session_id, "result")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def command_main(argv):
|
|
198
|
+
"""Small shell-friendly view of the same client, useful to any local harness."""
|
|
199
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
200
|
+
parser.add_argument("action", choices=("status", "exec", "check", "submit", "close", "result", "read", "write"))
|
|
201
|
+
parser.add_argument("value", nargs="?")
|
|
202
|
+
parser.add_argument("--key")
|
|
203
|
+
parser.add_argument("--seconds", type=float, default=120)
|
|
204
|
+
parser.add_argument("--export", help="Export a terminal result to a new directory")
|
|
205
|
+
args = parser.parse_args(argv)
|
|
206
|
+
client = Client(os.environ.get("ICLAYOUT_BENCH_ENDPOINT", ""), os.environ.get("ICLAYOUT_BENCH_TOKEN", ""))
|
|
207
|
+
sid = os.environ.get("ICLAYOUT_BENCH_SESSION", "")
|
|
208
|
+
if args.action in {"exec", "check"}:
|
|
209
|
+
started = (client.check(sid, key=args.key) if args.action == "check" else
|
|
210
|
+
client.execute(sid, args.value, args.seconds, key=args.key))
|
|
211
|
+
offset = 0
|
|
212
|
+
while True:
|
|
213
|
+
status = client.poll(sid, started["execution_id"], offset=offset)
|
|
214
|
+
raw = base64.b64decode(status["log_base64"], validate=True)
|
|
215
|
+
sys.stdout.buffer.write(raw)
|
|
216
|
+
sys.stdout.buffer.flush()
|
|
217
|
+
offset = status["next_offset"]
|
|
218
|
+
if status["state"] != "running" and not raw:
|
|
219
|
+
print(json.dumps({k: status[k] for k in ("state", "exit_code", "truncated")}), file=sys.stderr)
|
|
220
|
+
return 0 if status["exit_code"] == 0 else 1
|
|
221
|
+
time.sleep(.2)
|
|
222
|
+
elif args.action == "submit":
|
|
223
|
+
result = client.submit(sid, args.value, key=args.key)
|
|
224
|
+
elif args.action == "close":
|
|
225
|
+
result = client.close(sid, key=args.key)
|
|
226
|
+
elif args.action == "read":
|
|
227
|
+
sys.stdout.buffer.write(client.read(sid, args.value))
|
|
228
|
+
return 0
|
|
229
|
+
elif args.action == "write":
|
|
230
|
+
result = client.write(sid, args.value, sys.stdin.buffer.read(MAX_RESPONSE_BYTES), key=args.key)
|
|
231
|
+
elif args.action == "result":
|
|
232
|
+
result = client.result(sid)
|
|
233
|
+
if args.export:
|
|
234
|
+
from .analysis import export_session_result
|
|
235
|
+
export_session_result(result, args.export)
|
|
236
|
+
else:
|
|
237
|
+
result = client.session(sid)
|
|
238
|
+
print(json.dumps(result, allow_nan=False))
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def main(argv=None):
|
|
243
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
244
|
+
if argv and argv[0] in {"status", "exec", "check", "submit", "close", "result", "read", "write"}:
|
|
245
|
+
try:
|
|
246
|
+
return command_main(argv)
|
|
247
|
+
except (ClientError, ValueError, OSError) as error:
|
|
248
|
+
print(str(error), file=sys.stderr)
|
|
249
|
+
return 1
|
|
250
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
251
|
+
parser.add_argument("--endpoint", default=os.environ.get("ICLAYOUT_BENCH_ENDPOINT"))
|
|
252
|
+
parser.add_argument("--token-env", default="ICLAYOUT_BENCH_TOKEN")
|
|
253
|
+
parser.add_argument("--timeout", type=float, default=30)
|
|
254
|
+
parser.add_argument("--session", default=os.environ.get("ICLAYOUT_BENCH_SESSION"))
|
|
255
|
+
parser.add_argument("--key", help="Required for POST; retain this value for retries")
|
|
256
|
+
parser.add_argument("method", choices=("GET", "POST"))
|
|
257
|
+
parser.add_argument("operation", help="Session-relative route, or sessions for creation")
|
|
258
|
+
parser.add_argument("--json", default="-", help="POST body file; default reads stdin")
|
|
259
|
+
args = parser.parse_args(argv)
|
|
260
|
+
try:
|
|
261
|
+
if not args.endpoint:
|
|
262
|
+
raise ValueError("Set --endpoint or ICLAYOUT_BENCH_ENDPOINT")
|
|
263
|
+
client = Client(args.endpoint, os.environ.get(args.token_env, ""), timeout=args.timeout)
|
|
264
|
+
body = None
|
|
265
|
+
if args.method == "POST":
|
|
266
|
+
if args.json == "-":
|
|
267
|
+
body = json.load(sys.stdin)
|
|
268
|
+
else:
|
|
269
|
+
with open(args.json) as stream:
|
|
270
|
+
body = json.load(stream)
|
|
271
|
+
if args.session:
|
|
272
|
+
path = f"/sessions/{_identifier(args.session)}"
|
|
273
|
+
if args.operation != "status":
|
|
274
|
+
path += "/" + args.operation
|
|
275
|
+
else:
|
|
276
|
+
if args.operation != "sessions":
|
|
277
|
+
raise ValueError("Set --session for session operations")
|
|
278
|
+
path = "/sessions"
|
|
279
|
+
result = client.request(args.method, path, body, key=args.key)
|
|
280
|
+
print(json.dumps(result, allow_nan=False))
|
|
281
|
+
except (ClientError, ValueError, OSError) as error:
|
|
282
|
+
code = error.code if isinstance(error, ClientError) else "invalid_request"
|
|
283
|
+
print(json.dumps({"error": {"code": code, "message": str(error)}}), file=sys.stderr)
|
|
284
|
+
return 1
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
raise SystemExit(main())
|