ids2eval 0.2.2__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.
- ids2eval/__init__.py +0 -0
- ids2eval/__main__.py +4 -0
- ids2eval/_json_utils.py +24 -0
- ids2eval/audit/__init__.py +239 -0
- ids2eval/audit/_auc.py +48 -0
- ids2eval/audit/_by_class.py +29 -0
- ids2eval/audit/_checkpoint.py +74 -0
- ids2eval/audit/_fit_score.py +40 -0
- ids2eval/audit/_materiality.py +28 -0
- ids2eval/audit/_status.py +15 -0
- ids2eval/audit/_time.py +14 -0
- ids2eval/audit/artifact_sensitivity.py +88 -0
- ids2eval/audit/class_distribution.py +57 -0
- ids2eval/audit/cross_capture_matrix.py +115 -0
- ids2eval/audit/cross_dataset_drift.py +74 -0
- ids2eval/audit/data_integrity.py +67 -0
- ids2eval/audit/dedup.py +67 -0
- ids2eval/audit/feature_auc_ranking.py +125 -0
- ids2eval/audit/feature_category_ablation.py +98 -0
- ids2eval/audit/flow_group_leakage.py +66 -0
- ids2eval/audit/homogeneity.py +160 -0
- ids2eval/audit/identity_columns.py +171 -0
- ids2eval/audit/known_issues.py +144 -0
- ids2eval/audit/label_conflict.py +92 -0
- ids2eval/audit/leakage.py +99 -0
- ids2eval/audit/near_duplicate_class.py +87 -0
- ids2eval/audit/one_rule.py +82 -0
- ids2eval/audit/port_protocol_shortcut.py +75 -0
- ids2eval/audit/repeated_seed_falsification.py +182 -0
- ids2eval/audit/resplit.py +92 -0
- ids2eval/audit/result_robustness.py +154 -0
- ids2eval/audit/row_order_leakage.py +86 -0
- ids2eval/audit/scenario_holdout.py +115 -0
- ids2eval/audit/schema_fingerprint.py +52 -0
- ids2eval/audit/seed_sensitivity.py +69 -0
- ids2eval/audit/synthetic_realism.py +73 -0
- ids2eval/audit/temporal_leakage.py +58 -0
- ids2eval/audit/temporal_realism.py +81 -0
- ids2eval/cli.py +284 -0
- ids2eval/config.py +362 -0
- ids2eval/dashboard/__init__.py +1 -0
- ids2eval/dashboard/__main__.py +3 -0
- ids2eval/dashboard/server.py +527 -0
- ids2eval/dashboard/static/app.js +1064 -0
- ids2eval/dashboard/static/charts.js +366 -0
- ids2eval/dashboard/static/index.html +33 -0
- ids2eval/dashboard/static/style.css +293 -0
- ids2eval/data/__init__.py +3 -0
- ids2eval/data/cache.py +99 -0
- ids2eval/data/chunked_io.py +387 -0
- ids2eval/data/dataset.py +208 -0
- ids2eval/data/features.py +95 -0
- ids2eval/data/label_grouping.py +38 -0
- ids2eval/modeling/__init__.py +3 -0
- ids2eval/modeling/benchmark.py +360 -0
- ids2eval/modeling/classifiers.py +145 -0
- ids2eval/modeling/preprocessing.py +73 -0
- ids2eval/reporting/__init__.py +2 -0
- ids2eval/reporting/cite.py +57 -0
- ids2eval/reporting/compare_datasets.py +103 -0
- ids2eval/reporting/drift.py +80 -0
- ids2eval/reporting/intervention_manifest.py +83 -0
- ids2eval/reporting/recommend.py +402 -0
- ids2eval/reporting/run_manager.py +184 -0
- ids2eval/reporting/scorecard.py +875 -0
- ids2eval/reporting/scorecard_plot.py +162 -0
- ids2eval/version_info.py +36 -0
- ids2eval-0.2.2.dist-info/METADATA +119 -0
- ids2eval-0.2.2.dist-info/RECORD +73 -0
- ids2eval-0.2.2.dist-info/WHEEL +5 -0
- ids2eval-0.2.2.dist-info/entry_points.txt +4 -0
- ids2eval-0.2.2.dist-info/licenses/LICENSE +661 -0
- ids2eval-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""The IDS2Eval dashboard's server: browse runs, read scorecards, launch runs,
|
|
2
|
+
cite a run, compare two datasets, and get structural fix recommendations for
|
|
3
|
+
a flagged/warned run - every CLI subcommand available here too.
|
|
4
|
+
|
|
5
|
+
ids2eval-dashboard --output ./output # then open http://127.0.0.1:8765
|
|
6
|
+
|
|
7
|
+
The page itself lives next to this file, in static/ (index.html, style.css,
|
|
8
|
+
app.js) - plain files, served as-is, so they can be opened and edited
|
|
9
|
+
directly. See README.md in this folder for the endpoints and troubleshooting.
|
|
10
|
+
|
|
11
|
+
Standard library only (http.server), so it adds no dependencies. A run is
|
|
12
|
+
launched as a real `python -m ids2eval` subprocess - exactly what the CLI
|
|
13
|
+
does - so the UI can't drift from the CLI's behavior; it only writes the
|
|
14
|
+
YAML to a temp file and streams the log back.
|
|
15
|
+
|
|
16
|
+
Local by design: it binds to 127.0.0.1, and every request must carry a
|
|
17
|
+
localhost Host header (blocks DNS rebinding) and every POST a JSON body
|
|
18
|
+
from a same-origin page (blocks a random website from POSTing a config
|
|
19
|
+
that reads arbitrary files). Anyone who can reach the port can run a
|
|
20
|
+
config, so don't bind it to a public interface.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import csv
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import tempfile
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
import webbrowser
|
|
36
|
+
from collections import deque
|
|
37
|
+
from http import HTTPStatus
|
|
38
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from urllib.parse import unquote, urlsplit
|
|
41
|
+
|
|
42
|
+
import yaml
|
|
43
|
+
|
|
44
|
+
from ..config import load_config
|
|
45
|
+
from ..reporting import cite, compare_datasets, recommend, run_manager, scorecard
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
50
|
+
_LOG_LINES = 2000
|
|
51
|
+
_MAX_BODY = 1_000_000
|
|
52
|
+
_CONTENT_TYPES = {
|
|
53
|
+
".html": "text/html; charset=utf-8",
|
|
54
|
+
".css": "text/css; charset=utf-8",
|
|
55
|
+
".js": "text/javascript; charset=utf-8",
|
|
56
|
+
".md": "text/markdown; charset=utf-8",
|
|
57
|
+
".json": "application/json",
|
|
58
|
+
".csv": "text/csv; charset=utf-8",
|
|
59
|
+
".png": "image/png",
|
|
60
|
+
".pdf": "application/pdf",
|
|
61
|
+
".parquet": "application/octet-stream",
|
|
62
|
+
}
|
|
63
|
+
_MINIMAL_STARTER_CONFIG = """\
|
|
64
|
+
# Minimal config - every other field falls back to its default.
|
|
65
|
+
# Every field explained, with defaults and examples: configs/README.md
|
|
66
|
+
dataset:
|
|
67
|
+
name: my-dataset
|
|
68
|
+
raw_files: [path/to/data.csv] # or train_file + test_file
|
|
69
|
+
schema:
|
|
70
|
+
label_column: Label
|
|
71
|
+
audit:
|
|
72
|
+
# resplit_falsification (on by default) needs dataset.group_columns; off here
|
|
73
|
+
# since most datasets don't have such columns loaded, see configs/README.md
|
|
74
|
+
resplit_falsification: false
|
|
75
|
+
classifiers:
|
|
76
|
+
list: [DecisionTree, RandomForest]
|
|
77
|
+
output:
|
|
78
|
+
dir: ./output
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
# The repo's own config schema (every field, its real default, required fields
|
|
82
|
+
# marked), one directory up from ids2eval/dashboard/server.py's install location.
|
|
83
|
+
# Only resolvable from an editable install (`pip install -e .`, this project's
|
|
84
|
+
# only documented install path) or a repo checkout; _build_starter_config() falls
|
|
85
|
+
# back to _MINIMAL_STARTER_CONFIG when it isn't there, e.g. a built wheel.
|
|
86
|
+
_SCHEMA_YAML_PATH = Path(__file__).resolve().parents[2] / "configs" / "schema.yaml"
|
|
87
|
+
|
|
88
|
+
# resplit_falsification defaults to true in configs/schema.yaml (an accurate
|
|
89
|
+
# reflection of the real default), but that trips audit.resplit_falsification's
|
|
90
|
+
# dataset.group_columns requirement on the very first run for most datasets, which
|
|
91
|
+
# don't have session-identifying columns loaded at all. Patched to false here, for
|
|
92
|
+
# the editor's starting point only - schema.yaml on disk is left untouched, since
|
|
93
|
+
# it's meant to state the real default, not a dashboard-friendly one.
|
|
94
|
+
_RESPLIT_BLOCK = " resplit_falsification: true # optional, needs dataset.group_columns set"
|
|
95
|
+
_RESPLIT_REPLACEMENT = (
|
|
96
|
+
" resplit_falsification: false # true is the real default; off here since most datasets "
|
|
97
|
+
"have no dataset.group_columns to build its comparison split from, see configs/README.md"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _build_starter_config() -> str:
|
|
102
|
+
try:
|
|
103
|
+
text = _SCHEMA_YAML_PATH.read_text()
|
|
104
|
+
except OSError:
|
|
105
|
+
return _MINIMAL_STARTER_CONFIG
|
|
106
|
+
if _RESPLIT_BLOCK not in text:
|
|
107
|
+
# configs/schema.yaml's wording moved on without this patch - fall back
|
|
108
|
+
# rather than silently ship a starter that still trips the same wall.
|
|
109
|
+
logger.warning(
|
|
110
|
+
"configs/schema.yaml's resplit_falsification comment changed; "
|
|
111
|
+
"New run will start from the minimal template instead"
|
|
112
|
+
)
|
|
113
|
+
return _MINIMAL_STARTER_CONFIG
|
|
114
|
+
return text.replace(_RESPLIT_BLOCK, _RESPLIT_REPLACEMENT, 1)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_STARTER_CONFIG = _build_starter_config()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Job:
|
|
121
|
+
"""At most one run at a time: runs are CPU/RAM heavy, and two sharing
|
|
122
|
+
one output.dir would race on the load-cache.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self):
|
|
126
|
+
self.lock = threading.Lock()
|
|
127
|
+
self.proc: subprocess.Popen | None = None
|
|
128
|
+
self.log: deque[str] = deque(maxlen=_LOG_LINES)
|
|
129
|
+
self.state = "idle"
|
|
130
|
+
self.returncode: int | None = None
|
|
131
|
+
self.output_dir: str | None = None
|
|
132
|
+
self.started_at: float | None = None
|
|
133
|
+
self.finished_at: float | None = None
|
|
134
|
+
self.last_output_at: float | None = None
|
|
135
|
+
|
|
136
|
+
def start(self, config_path: Path, output_dir: Path, skip_audit: bool, skip_benchmark: bool) -> None:
|
|
137
|
+
cmd = [sys.executable, "-m", "ids2eval", "--config", str(config_path)]
|
|
138
|
+
if skip_audit:
|
|
139
|
+
cmd.append("--skip-audit")
|
|
140
|
+
if skip_benchmark:
|
|
141
|
+
cmd.append("--skip-benchmark")
|
|
142
|
+
self.log.clear()
|
|
143
|
+
self.log.append("$ " + " ".join(cmd))
|
|
144
|
+
# Unbuffered so each line reaches the live log as it's written (a pipe
|
|
145
|
+
# would otherwise block-buffer stdout); UTF-8 so a non-ASCII class or
|
|
146
|
+
# feature name can't fail decoding on a Windows code page.
|
|
147
|
+
env = {**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"}
|
|
148
|
+
self.proc = subprocess.Popen( # noqa: S603 - argv list, no shell; config was validated first
|
|
149
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
|
|
150
|
+
encoding="utf-8", errors="replace", env=env,
|
|
151
|
+
)
|
|
152
|
+
self.state, self.returncode, self.output_dir = "running", None, str(output_dir)
|
|
153
|
+
self.started_at = self.last_output_at = time.time()
|
|
154
|
+
self.finished_at = None
|
|
155
|
+
threading.Thread(target=self._pump, args=(self.proc, config_path), daemon=True).start()
|
|
156
|
+
|
|
157
|
+
def _pump(self, proc: subprocess.Popen, config_path: Path) -> None:
|
|
158
|
+
for line in proc.stdout:
|
|
159
|
+
self.log.append(line.rstrip("\n"))
|
|
160
|
+
self.last_output_at = time.time()
|
|
161
|
+
proc.wait()
|
|
162
|
+
with self.lock:
|
|
163
|
+
self.finished_at = time.time()
|
|
164
|
+
self.returncode = proc.returncode
|
|
165
|
+
self.state = "completed" if proc.returncode == 0 else "failed"
|
|
166
|
+
config_path.unlink(missing_ok=True)
|
|
167
|
+
|
|
168
|
+
def snapshot(self) -> dict:
|
|
169
|
+
# Server-side clock in the payload, so the page's elapsed/idle timers
|
|
170
|
+
# don't depend on the browser's clock agreeing with the server's.
|
|
171
|
+
return {"state": self.state, "returncode": self.returncode, "output_dir": self.output_dir,
|
|
172
|
+
"started_at": self.started_at, "finished_at": self.finished_at,
|
|
173
|
+
"last_output_at": self.last_output_at, "now": time.time(), "log": list(self.log)}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class App:
|
|
177
|
+
def __init__(self, output_dirs: list[Path], starter_config: str):
|
|
178
|
+
self.output_dirs = [p.resolve() for p in output_dirs]
|
|
179
|
+
self.starter_config = starter_config
|
|
180
|
+
self.job = Job()
|
|
181
|
+
|
|
182
|
+
def add_output_dir(self, path: Path) -> None:
|
|
183
|
+
path = path.resolve()
|
|
184
|
+
if path not in self.output_dirs:
|
|
185
|
+
self.output_dirs.append(path)
|
|
186
|
+
|
|
187
|
+
def run_dir(self, dir_idx: str, run_name: str) -> Path | None:
|
|
188
|
+
if not dir_idx.isdigit() or int(dir_idx) >= len(self.output_dirs):
|
|
189
|
+
return None
|
|
190
|
+
runs_root = self.output_dirs[int(dir_idx)] / run_manager.RUNS_DIRNAME
|
|
191
|
+
candidate = (runs_root / run_name).resolve()
|
|
192
|
+
if candidate.parent != runs_root.resolve() or not candidate.is_dir():
|
|
193
|
+
return None
|
|
194
|
+
return candidate
|
|
195
|
+
|
|
196
|
+
def list_runs(self) -> list[dict]:
|
|
197
|
+
runs = []
|
|
198
|
+
for idx, out in enumerate(self.output_dirs):
|
|
199
|
+
runs_root = out / run_manager.RUNS_DIRNAME
|
|
200
|
+
if not runs_root.is_dir():
|
|
201
|
+
continue
|
|
202
|
+
for run_dir in runs_root.iterdir():
|
|
203
|
+
if run_dir.is_dir():
|
|
204
|
+
runs.append(_run_summary(idx, out, run_dir))
|
|
205
|
+
return sorted(runs, key=lambda r: r["name"], reverse=True)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _read_scorecard(run_dir: Path) -> dict | None:
|
|
209
|
+
sc = _read_json(run_dir / "scorecard.json")
|
|
210
|
+
return scorecard.upgrade(sc) if isinstance(sc, dict) else None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _read_json(path: Path) -> dict | list | None:
|
|
214
|
+
try:
|
|
215
|
+
return json.loads(path.read_text())
|
|
216
|
+
except (OSError, ValueError):
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _run_summary(idx: int, output_dir: Path, run_dir: Path) -> dict:
|
|
221
|
+
sc = _read_scorecard(run_dir) or {}
|
|
222
|
+
status = _read_json(run_dir / "run_status.json") or {}
|
|
223
|
+
cfg = _read_json(run_dir / "resolved_config.json") or {}
|
|
224
|
+
fp = sc.get("dataset_fingerprint") or {}
|
|
225
|
+
return {
|
|
226
|
+
"id": f"{idx}/{run_dir.name}",
|
|
227
|
+
"name": run_dir.name,
|
|
228
|
+
"output_dir": str(output_dir),
|
|
229
|
+
"dataset": sc.get("dataset_name") or cfg.get("dataset", {}).get("name"),
|
|
230
|
+
"run_status": status.get("status", "running"),
|
|
231
|
+
"failed_stage": status.get("failed_stage"),
|
|
232
|
+
"error": status.get("error"),
|
|
233
|
+
"verdict": sc.get("overall_status"),
|
|
234
|
+
"generated_at": sc.get("generated_at"),
|
|
235
|
+
"counts": sc.get("counts"),
|
|
236
|
+
"train_rows": fp.get("train_rows"),
|
|
237
|
+
"test_rows": fp.get("test_rows"),
|
|
238
|
+
"feature_count": fp.get("feature_count"),
|
|
239
|
+
# Each check's final status (after dedup when it ran), for the
|
|
240
|
+
# dashboard's cross-run views without fetching every scorecard.
|
|
241
|
+
"check_status": {c["check"]: (c.get("after") or c.get("before") or {}).get("status")
|
|
242
|
+
for c in sc.get("checks", []) if "check" in c},
|
|
243
|
+
"files": sorted(p.name for p in run_dir.iterdir() if p.is_file()),
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _benchmark_rows(run_dir: Path) -> list[dict]:
|
|
248
|
+
path = run_dir / "benchmark_results.csv"
|
|
249
|
+
if not path.exists():
|
|
250
|
+
return []
|
|
251
|
+
with path.open(newline="") as f:
|
|
252
|
+
return list(csv.DictReader(f))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class Handler(BaseHTTPRequestHandler):
|
|
256
|
+
app: App
|
|
257
|
+
allowed_hosts: set[str]
|
|
258
|
+
|
|
259
|
+
def log_message(self, fmt, *args):
|
|
260
|
+
logger.debug("%s - %s", self.address_string(), fmt % args)
|
|
261
|
+
|
|
262
|
+
def _host_ok(self) -> bool:
|
|
263
|
+
return (self.headers.get("Host") or "") in self.allowed_hosts
|
|
264
|
+
|
|
265
|
+
def _send(self, status: int, body: bytes, content_type: str, extra: dict | None = None) -> None:
|
|
266
|
+
self.send_response(status)
|
|
267
|
+
self.send_header("Content-Type", content_type)
|
|
268
|
+
self.send_header("Content-Length", str(len(body)))
|
|
269
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
270
|
+
self.send_header("Cache-Control", "no-store")
|
|
271
|
+
for k, v in (extra or {}).items():
|
|
272
|
+
self.send_header(k, v)
|
|
273
|
+
self.end_headers()
|
|
274
|
+
self.wfile.write(body)
|
|
275
|
+
|
|
276
|
+
def _json(self, payload, status: int = HTTPStatus.OK) -> None:
|
|
277
|
+
self._send(status, json.dumps(payload).encode(), "application/json")
|
|
278
|
+
|
|
279
|
+
def _error(self, status: int, message: str) -> None:
|
|
280
|
+
self._json({"error": message}, status)
|
|
281
|
+
|
|
282
|
+
def do_GET(self):
|
|
283
|
+
if not self._host_ok():
|
|
284
|
+
return self._error(HTTPStatus.FORBIDDEN, "bad Host header")
|
|
285
|
+
path = unquote(urlsplit(self.path).path)
|
|
286
|
+
if path == "/" or path.startswith("/static/"):
|
|
287
|
+
return self._static("index.html" if path == "/" else path.removeprefix("/static/"))
|
|
288
|
+
if path == "/api/runs":
|
|
289
|
+
return self._json(self.app.list_runs())
|
|
290
|
+
if path == "/api/job":
|
|
291
|
+
return self._json(self.app.job.snapshot())
|
|
292
|
+
if path == "/api/starter-config":
|
|
293
|
+
return self._json({"yaml": self.app.starter_config})
|
|
294
|
+
if path == "/api/check-info":
|
|
295
|
+
# The same "what it checks" / "rule" text SCORECARD.html shows on hover.
|
|
296
|
+
return self._json({k: {"what": what, "rule": rule}
|
|
297
|
+
for k, (what, rule) in scorecard.CHECK_INFO.items()})
|
|
298
|
+
|
|
299
|
+
parts = path.strip("/").split("/")
|
|
300
|
+
# /api/run/<dir_idx>/<run_name> - one run's detail
|
|
301
|
+
if len(parts) == 4 and parts[:2] == ["api", "run"]:
|
|
302
|
+
run_dir = self.app.run_dir(parts[2], parts[3])
|
|
303
|
+
if run_dir is None:
|
|
304
|
+
return self._error(HTTPStatus.NOT_FOUND, "no such run")
|
|
305
|
+
summary = _run_summary(int(parts[2]), self.app.output_dirs[int(parts[2])], run_dir)
|
|
306
|
+
return self._json({**summary, "scorecard": _read_scorecard(run_dir),
|
|
307
|
+
"audit_report": _read_json(run_dir / "audit_report_after.json")
|
|
308
|
+
or _read_json(run_dir / "audit_report_before.json"),
|
|
309
|
+
# dedup_check's per-class rates only exist before dedup
|
|
310
|
+
"audit_report_before": _read_json(run_dir / "audit_report_before.json"),
|
|
311
|
+
"environment": _read_json(run_dir / "environment.json"),
|
|
312
|
+
"benchmark": _benchmark_rows(run_dir)})
|
|
313
|
+
# /api/run/<dir_idx>/<run_name>/citation - the same BibTeX `ids2eval cite` prints
|
|
314
|
+
if len(parts) == 5 and parts[:2] == ["api", "run"] and parts[4] == "citation":
|
|
315
|
+
run_dir = self.app.run_dir(parts[2], parts[3])
|
|
316
|
+
if run_dir is None or not (run_dir / "scorecard.json").is_file():
|
|
317
|
+
return self._error(HTTPStatus.NOT_FOUND, "no scorecard.json for this run")
|
|
318
|
+
try:
|
|
319
|
+
bibtex = cite.citation_bibtex(cite.load_scorecard(run_dir))
|
|
320
|
+
except (OSError, ValueError, KeyError) as e:
|
|
321
|
+
return self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(e))
|
|
322
|
+
return self._json({"bibtex": bibtex})
|
|
323
|
+
# /api/run/<dir_idx>/<run_name>/recommendations - structural fix suggestions
|
|
324
|
+
if len(parts) == 5 and parts[:2] == ["api", "run"] and parts[4] == "recommendations":
|
|
325
|
+
run_dir = self.app.run_dir(parts[2], parts[3])
|
|
326
|
+
if run_dir is None or not (run_dir / "resolved_config.json").is_file():
|
|
327
|
+
return self._error(HTTPStatus.NOT_FOUND, "no resolved_config.json for this run")
|
|
328
|
+
try:
|
|
329
|
+
recs, _ = recommend.recommendations_for_run(run_dir)
|
|
330
|
+
except (OSError, ValueError, KeyError) as e:
|
|
331
|
+
return self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(e))
|
|
332
|
+
return self._json({"recommendations": recs})
|
|
333
|
+
# /files/<dir_idx>/<run_name>/<file> - a run artifact, top level of the run dir only
|
|
334
|
+
if len(parts) == 4 and parts[0] == "files":
|
|
335
|
+
run_dir = self.app.run_dir(parts[1], parts[2])
|
|
336
|
+
target = run_dir / parts[3] if run_dir else None
|
|
337
|
+
if target is None or "/" in parts[3] or parts[3].startswith(".") or not target.is_file():
|
|
338
|
+
return self._error(HTTPStatus.NOT_FOUND, "no such file")
|
|
339
|
+
ctype = _CONTENT_TYPES.get(target.suffix.lower(), "application/octet-stream")
|
|
340
|
+
extra = {}
|
|
341
|
+
if target.suffix.lower() == ".html":
|
|
342
|
+
# Only the scorecard's own inline styles - no scripts, nothing external.
|
|
343
|
+
extra["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'"
|
|
344
|
+
elif ctype == "application/octet-stream":
|
|
345
|
+
extra["Content-Disposition"] = f'attachment; filename="{target.name}"'
|
|
346
|
+
return self._send(HTTPStatus.OK, target.read_bytes(), ctype, extra)
|
|
347
|
+
return self._error(HTTPStatus.NOT_FOUND, "not found")
|
|
348
|
+
|
|
349
|
+
def _static(self, name: str) -> None:
|
|
350
|
+
# Only files directly inside static/ - no subdirectories, no dotfiles, no "..".
|
|
351
|
+
target = STATIC_DIR / name
|
|
352
|
+
if "/" in name or name.startswith(".") or not target.is_file():
|
|
353
|
+
return self._error(HTTPStatus.NOT_FOUND, "not found")
|
|
354
|
+
ctype = _CONTENT_TYPES.get(target.suffix.lower(), "application/octet-stream")
|
|
355
|
+
extra = {}
|
|
356
|
+
if target.suffix == ".html":
|
|
357
|
+
# Everything is a same-origin file now, so no inline script/style is allowed at all.
|
|
358
|
+
extra["Content-Security-Policy"] = "default-src 'self'; frame-ancestors 'none'"
|
|
359
|
+
return self._send(HTTPStatus.OK, target.read_bytes(), ctype, extra)
|
|
360
|
+
|
|
361
|
+
def do_POST(self):
|
|
362
|
+
if not self._host_ok():
|
|
363
|
+
return self._error(HTTPStatus.FORBIDDEN, "bad Host header")
|
|
364
|
+
origin = self.headers.get("Origin")
|
|
365
|
+
if origin is not None and urlsplit(origin).netloc not in self.allowed_hosts:
|
|
366
|
+
return self._error(HTTPStatus.FORBIDDEN, "cross-origin request refused")
|
|
367
|
+
# A JSON content type can't be sent cross-site without a CORS preflight, which this server never approves.
|
|
368
|
+
if not (self.headers.get("Content-Type") or "").startswith("application/json"):
|
|
369
|
+
return self._error(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "expected application/json")
|
|
370
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
371
|
+
if length > _MAX_BODY:
|
|
372
|
+
return self._error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "body too large")
|
|
373
|
+
try:
|
|
374
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
375
|
+
except ValueError:
|
|
376
|
+
return self._error(HTTPStatus.BAD_REQUEST, "invalid JSON")
|
|
377
|
+
|
|
378
|
+
path = urlsplit(self.path).path
|
|
379
|
+
if path == "/api/validate":
|
|
380
|
+
cfg, err = _validate(body.get("yaml", ""))
|
|
381
|
+
return self._json({"ok": err is None, "error": err,
|
|
382
|
+
"output_dir": cfg["output"]["dir"] if cfg else None})
|
|
383
|
+
if path == "/api/run":
|
|
384
|
+
return self._start_run(body)
|
|
385
|
+
if path == "/api/compare-datasets":
|
|
386
|
+
return self._compare_datasets(body)
|
|
387
|
+
if path == "/api/job/stop":
|
|
388
|
+
job = self.app.job
|
|
389
|
+
with job.lock:
|
|
390
|
+
if job.state == "running" and job.proc is not None:
|
|
391
|
+
job.proc.terminate()
|
|
392
|
+
job.log.append("[stopped from the web UI]")
|
|
393
|
+
return self._json(job.snapshot())
|
|
394
|
+
|
|
395
|
+
parts = path.strip("/").split("/")
|
|
396
|
+
# /api/run/<dir_idx>/<run_name>/apply-recommendations - merge selected
|
|
397
|
+
# patches onto the run's resolved config, return the result as YAML
|
|
398
|
+
# ready to prefill the New run editor.
|
|
399
|
+
if len(parts) == 5 and parts[:2] == ["api", "run"] and parts[4] == "apply-recommendations":
|
|
400
|
+
run_dir = self.app.run_dir(parts[2], parts[3])
|
|
401
|
+
if run_dir is None or not (run_dir / "resolved_config.json").is_file():
|
|
402
|
+
return self._error(HTTPStatus.NOT_FOUND, "no resolved_config.json for this run")
|
|
403
|
+
try:
|
|
404
|
+
recs, cfg = recommend.recommendations_for_run(run_dir)
|
|
405
|
+
patched = recommend.apply_recommendations(cfg, recs, body.get("ids") or [])
|
|
406
|
+
except (OSError, ValueError, KeyError) as e:
|
|
407
|
+
return self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(e))
|
|
408
|
+
return self._json({"yaml": yaml.safe_dump(patched, sort_keys=False)})
|
|
409
|
+
return self._error(HTTPStatus.NOT_FOUND, "not found")
|
|
410
|
+
|
|
411
|
+
def _compare_datasets(self, body: dict) -> None:
|
|
412
|
+
"""The same comparison `ids2eval compare-datasets` runs, over two pasted
|
|
413
|
+
configs rather than two file paths - loads both datasets fresh, so
|
|
414
|
+
it's exactly as heavy as the CLI command, just without a subprocess.
|
|
415
|
+
"""
|
|
416
|
+
yaml_a, yaml_b = body.get("yaml_a", ""), body.get("yaml_b", "")
|
|
417
|
+
if not yaml_a.strip() or not yaml_b.strip():
|
|
418
|
+
return self._error(HTTPStatus.BAD_REQUEST, "both configs are required")
|
|
419
|
+
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as fa, \
|
|
420
|
+
tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as fb:
|
|
421
|
+
fa.write(yaml_a)
|
|
422
|
+
fb.write(yaml_b)
|
|
423
|
+
try:
|
|
424
|
+
result = compare_datasets.compare(fa.name, fb.name)
|
|
425
|
+
return self._json({"result": result, "report": compare_datasets.render_report(result)})
|
|
426
|
+
except Exception as e: # bad YAML, missing files, schema errors - report, never crash the server
|
|
427
|
+
return self._error(HTTPStatus.BAD_REQUEST, f"{type(e).__name__}: {e}")
|
|
428
|
+
finally:
|
|
429
|
+
Path(fa.name).unlink(missing_ok=True)
|
|
430
|
+
Path(fb.name).unlink(missing_ok=True)
|
|
431
|
+
|
|
432
|
+
def _start_run(self, body: dict) -> None:
|
|
433
|
+
job = self.app.job
|
|
434
|
+
with job.lock:
|
|
435
|
+
if job.state == "running":
|
|
436
|
+
return self._error(HTTPStatus.CONFLICT, "a run is already in progress")
|
|
437
|
+
cfg, err = _validate(body.get("yaml", ""))
|
|
438
|
+
if err:
|
|
439
|
+
return self._error(HTTPStatus.BAD_REQUEST, err)
|
|
440
|
+
fd, name = tempfile.mkstemp(prefix="ids2eval-ui-", suffix=".yaml")
|
|
441
|
+
with open(fd, "w") as f:
|
|
442
|
+
f.write(body["yaml"])
|
|
443
|
+
output_dir = Path(cfg["output"]["dir"])
|
|
444
|
+
self.app.add_output_dir(output_dir)
|
|
445
|
+
job.start(Path(name), output_dir, bool(body.get("skip_audit")), bool(body.get("skip_benchmark")))
|
|
446
|
+
return self._json(job.snapshot())
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _validate(yaml_text: str) -> tuple[dict | None, str | None]:
|
|
450
|
+
"""The same load_config() the CLI runs, so a config the UI accepts is
|
|
451
|
+
one the CLI will too - errors surface before a subprocess is spawned.
|
|
452
|
+
"""
|
|
453
|
+
if not yaml_text.strip():
|
|
454
|
+
return None, "config is empty"
|
|
455
|
+
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f:
|
|
456
|
+
f.write(yaml_text)
|
|
457
|
+
try:
|
|
458
|
+
return load_config(f.name), None
|
|
459
|
+
except Exception as e: # yaml errors, validation ValueErrors, bad shapes - all reported, never crash
|
|
460
|
+
return None, f"{type(e).__name__}: {e}"
|
|
461
|
+
finally:
|
|
462
|
+
Path(f.name).unlink(missing_ok=True)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def make_server(host: str, port: int, app: App) -> ThreadingHTTPServer:
|
|
466
|
+
allowed = {f"{h}:{port}" for h in ("127.0.0.1", "localhost", "[::1]", host)}
|
|
467
|
+
handler = type("BoundHandler", (Handler,), {"app": app, "allowed_hosts": allowed})
|
|
468
|
+
return ThreadingHTTPServer((host, port), handler)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _resolve_starter(config_path: str | None, output_args: list[str]) -> tuple[str, list[Path]]:
|
|
472
|
+
"""--config is entirely optional and never fatal: a missing or invalid path
|
|
473
|
+
falls back to _STARTER_CONFIG with a logged warning rather than crashing the
|
|
474
|
+
dashboard before it can even open. The bare `ids2eval-dashboard` invocation
|
|
475
|
+
(no flags at all) is the intended first-run path, not a degraded one.
|
|
476
|
+
"""
|
|
477
|
+
starter = _STARTER_CONFIG
|
|
478
|
+
output_dirs = [Path(p) for p in output_args]
|
|
479
|
+
if config_path:
|
|
480
|
+
try:
|
|
481
|
+
starter = Path(config_path).read_text()
|
|
482
|
+
except OSError as e:
|
|
483
|
+
logger.warning("--config %s isn't readable (%s); opening with the starter template instead",
|
|
484
|
+
config_path, e)
|
|
485
|
+
else:
|
|
486
|
+
cfg, err = _validate(starter)
|
|
487
|
+
if cfg:
|
|
488
|
+
output_dirs.append(Path(cfg["output"]["dir"]))
|
|
489
|
+
else:
|
|
490
|
+
logger.warning("--config doesn't validate yet (%s); prefilling it anyway", err)
|
|
491
|
+
if not output_dirs:
|
|
492
|
+
output_dirs = [Path("./output")]
|
|
493
|
+
return starter, output_dirs
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def main(argv=None) -> None:
|
|
497
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
498
|
+
parser = argparse.ArgumentParser(description="IDS2Eval dashboard (local web UI)")
|
|
499
|
+
parser.add_argument("--output", action="append", default=[],
|
|
500
|
+
help="An output.dir whose runs to list (repeatable; default ./output)")
|
|
501
|
+
parser.add_argument("--config", help="Optional: a YAML config to prefill the New run editor with")
|
|
502
|
+
parser.add_argument("--host", default="127.0.0.1", help="Interface to bind (default 127.0.0.1 - keep it local)")
|
|
503
|
+
parser.add_argument("--port", type=int, default=8765)
|
|
504
|
+
parser.add_argument("--no-browser", action="store_true", help="Don't open a browser tab")
|
|
505
|
+
args = parser.parse_args(argv)
|
|
506
|
+
|
|
507
|
+
starter, output_dirs = _resolve_starter(args.config, args.output)
|
|
508
|
+
|
|
509
|
+
if args.host not in ("127.0.0.1", "localhost", "::1"):
|
|
510
|
+
logger.warning("Binding to %s: anyone who can reach this port can run configs on this machine", args.host)
|
|
511
|
+
|
|
512
|
+
app = App(output_dirs, starter)
|
|
513
|
+
server = make_server(args.host, args.port, app)
|
|
514
|
+
url = f"http://{'localhost' if args.host in ('127.0.0.1', '::1') else args.host}:{args.port}/"
|
|
515
|
+
logger.info("IDS2Eval dashboard at %s (listing runs in: %s)", url, ", ".join(map(str, app.output_dirs)))
|
|
516
|
+
if not args.no_browser:
|
|
517
|
+
webbrowser.open(url)
|
|
518
|
+
try:
|
|
519
|
+
server.serve_forever()
|
|
520
|
+
except KeyboardInterrupt:
|
|
521
|
+
pass
|
|
522
|
+
finally:
|
|
523
|
+
server.server_close()
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
if __name__ == "__main__":
|
|
527
|
+
main()
|