mlsec-raid 0.4.0__tar.gz → 0.5.0__tar.gz

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.
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.3
2
+ Name: mlsec-raid
3
+ Version: 0.5.0
4
+ Summary: Client for the RAID competition system
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+
8
+ # raid
9
+
10
+ Python client for the RAID competition system.
11
+
12
+ ```bash
13
+ pip install mlsec-raid # or: uv tool install mlsec-raid
14
+ export RAID_KEY=<your-api-key> # or pass --api-key <your-api-key>
15
+
16
+ task-fetch <challenge> # downloads the challenge into ./<challenge>/
17
+ task-check <challenge> # scores it locally with scoring/score.py on its data, in a container
18
+ task-check method <challenge> # only the method: runs method/run like our backend's sanity check
19
+ task-check artifact <challenge> # only the artifact: scores artifact/result/
20
+ task-submit <challenge> # submits ./<challenge>/ without its data, scoring/ and .raidignore patterns
21
+ task-show # lists your jobs with their state (pending, running, success, failed) and
22
+ # score, and the submissions left today on challenges with a daily limit
23
+ task-log <job> # prints the log of a finished job, including the errors of your solution
24
+ ```
25
+
26
+ Every command also works as `task <command>`, e.g. `task submit <challenge>` or `task check method <challenge>`.
27
+
28
+ Challenges like dawn separate the method from the artifact:
29
+
30
+ ```
31
+ <challenge>/
32
+ artifact/data/ the data, never submitted
33
+ artifact/result/ the artifact you submit, e.g. dawn's re-implementations
34
+ method/run your method, which the backend runs once as a sanity check (dawn: on the simple target)
35
+ scoring/score.py method(), artifact() and score(), the backend's scoring
36
+ setup.sh your installs
37
+ ```
38
+
39
+ `task-check` runs `scoring/score.py` in the container `raid-<challenge>`, built like the backend's sandbox: with Docker an image on Debian trixie (slim) with python3 and the challenge's Debian packages (`scoring/packages.txt`), in which the challenge's `setup.sh` runs as root during the build; with only udocker (`pip install udocker`) a udocker container of `debian:trixie` set up the same way. It is built again only when `setup.sh` or the packages change (their hash is the image's `raid.setup` label, or `/etc/raid-setup.sha256` in the udocker container), or with `--rebuild`. Like on the backend, `score.py` sees only Debian's Python packages; unlike there, the method may use the internet. `task-check method` calls `score.py`'s `method()`, `task-check artifact` its `artifact()` and `task-check` its `score()`, which checks the method first and then the artifact. Challenges without that split (e.g. self-assessment) have only `task-check <challenge>`. `--no-container` checks on this machine instead.
40
+
41
+ The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
42
+
43
+ Challenge authors: `task-check <challenge> --hidden --reference` scores `reference/` on `hidden/` (the packages come from `challenge.yaml`). Admins upload a challenge directory with `task-push <directory>` and their own API key (created in the admin console): the directory's `challenge.yaml` sets the name, version, wave, baseline, badge and `submissions_per_day` (keys it leaves out keep their current values), and the upload becomes the active version.
44
+
45
+ Development: `uv sync` sets up the environment, `uv build` builds the package. Set `RAID_URL` to use another server. Release: bump `version` in `pyproject.toml`, then `uv build && uv publish` (PyPI token in `UV_PUBLISH_TOKEN`).
@@ -0,0 +1,38 @@
1
+ # raid
2
+
3
+ Python client for the RAID competition system.
4
+
5
+ ```bash
6
+ pip install mlsec-raid # or: uv tool install mlsec-raid
7
+ export RAID_KEY=<your-api-key> # or pass --api-key <your-api-key>
8
+
9
+ task-fetch <challenge> # downloads the challenge into ./<challenge>/
10
+ task-check <challenge> # scores it locally with scoring/score.py on its data, in a container
11
+ task-check method <challenge> # only the method: runs method/run like our backend's sanity check
12
+ task-check artifact <challenge> # only the artifact: scores artifact/result/
13
+ task-submit <challenge> # submits ./<challenge>/ without its data, scoring/ and .raidignore patterns
14
+ task-show # lists your jobs with their state (pending, running, success, failed) and
15
+ # score, and the submissions left today on challenges with a daily limit
16
+ task-log <job> # prints the log of a finished job, including the errors of your solution
17
+ ```
18
+
19
+ Every command also works as `task <command>`, e.g. `task submit <challenge>` or `task check method <challenge>`.
20
+
21
+ Challenges like dawn separate the method from the artifact:
22
+
23
+ ```
24
+ <challenge>/
25
+ artifact/data/ the data, never submitted
26
+ artifact/result/ the artifact you submit, e.g. dawn's re-implementations
27
+ method/run your method, which the backend runs once as a sanity check (dawn: on the simple target)
28
+ scoring/score.py method(), artifact() and score(), the backend's scoring
29
+ setup.sh your installs
30
+ ```
31
+
32
+ `task-check` runs `scoring/score.py` in the container `raid-<challenge>`, built like the backend's sandbox: with Docker an image on Debian trixie (slim) with python3 and the challenge's Debian packages (`scoring/packages.txt`), in which the challenge's `setup.sh` runs as root during the build; with only udocker (`pip install udocker`) a udocker container of `debian:trixie` set up the same way. It is built again only when `setup.sh` or the packages change (their hash is the image's `raid.setup` label, or `/etc/raid-setup.sha256` in the udocker container), or with `--rebuild`. Like on the backend, `score.py` sees only Debian's Python packages; unlike there, the method may use the internet. `task-check method` calls `score.py`'s `method()`, `task-check artifact` its `artifact()` and `task-check` its `score()`, which checks the method first and then the artifact. Challenges without that split (e.g. self-assessment) have only `task-check <challenge>`. `--no-container` checks on this machine instead.
33
+
34
+ The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
35
+
36
+ Challenge authors: `task-check <challenge> --hidden --reference` scores `reference/` on `hidden/` (the packages come from `challenge.yaml`). Admins upload a challenge directory with `task-push <directory>` and their own API key (created in the admin console): the directory's `challenge.yaml` sets the name, version, wave, baseline, badge and `submissions_per_day` (keys it leaves out keep their current values), and the upload becomes the active version.
37
+
38
+ Development: `uv sync` sets up the environment, `uv build` builds the package. Set `RAID_URL` to use another server. Release: bump `version` in `pyproject.toml`, then `uv build && uv publish` (PyPI token in `UV_PUBLISH_TOKEN`).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mlsec-raid"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Client for the RAID competition system"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -0,0 +1,457 @@
1
+ """Client for the RAID competition system: fetch challenges, check and submit solutions."""
2
+ import ast
3
+ import fnmatch
4
+ import importlib.util
5
+ import json
6
+ import os
7
+ import posixpath
8
+ import re
9
+ import shutil
10
+ import sys
11
+ import tarfile
12
+ import tempfile
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ from pathlib import Path
17
+
18
+ __all__ = ["Raid", "RaidError"]
19
+ __version__ = "0.5.0"
20
+
21
+ DEFAULT_URL = "https://raid.mlsec.tu-berlin.de"
22
+ PARTS = ("source", "data", "scoring") # source/ is unpacked into the challenge directory itself
23
+ # Parts of the challenge directory that are never submitted: these top-level entries and artifact/data/
24
+ OWN = {"data", "scoring", "hidden", "reference", "challenge.yaml", "entrypoint.sh"}
25
+ ARTIFACT_DATA = Path("artifact", "data") # the data/ part of challenges split into artifact/ and method/
26
+ CHECKS = {"method": "Method score", "artifact": "Artifact score", None: "Score"} # the part checked: what it prints
27
+ SKIP = {"__pycache__", ".git", ".ipynb_checkpoints", ".venv", "venv", ".DS_Store"}
28
+ NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
29
+
30
+
31
+ class RaidError(Exception):
32
+ pass
33
+
34
+
35
+ class Raid:
36
+ """raid = Raid(api_key); print(raid.howto())"""
37
+
38
+ def __init__(self, api_key=None, url=None, path="."):
39
+ self.api_key = (api_key or os.environ.get("RAID_KEY") or "").strip()
40
+ self.url = (url or os.environ.get("RAID_URL") or DEFAULT_URL).rstrip("/")
41
+ self.path = Path(path)
42
+
43
+ def howto(self):
44
+ """Explain the workflow."""
45
+ return self._json("/api/howto")["text"]
46
+
47
+ def fetch(self, challenge, force=False):
48
+ """Download a challenge into ./<challenge>/: its files, data/ (artifact/data/) and scoring/.
49
+
50
+ An existing directory is left untouched unless force=True, which overwrites the challenge files.
51
+ """
52
+ target = self.path / _check(challenge)
53
+ if target.exists() and not force:
54
+ print(f"{target}/ already exists. Fetch with force=True (task-fetch --force) to download it again "
55
+ "(this overwrites the challenge files).")
56
+ return target
57
+ info = self._json(f"/api/challenges/{challenge}")
58
+ with tempfile.TemporaryDirectory() as tmp:
59
+ for part in PARTS:
60
+ with self._request("GET", f"/api/challenges/{challenge}/{part}.tar.gz", timeout=300) as r, \
61
+ open(Path(tmp) / f"{part}.tar.gz", "wb") as f, \
62
+ _Progress(f"Downloading {part}", r.headers.get("Content-Length")) as bar:
63
+ while chunk := r.read(1 << 16):
64
+ f.write(chunk)
65
+ bar.update(len(chunk))
66
+ target.mkdir(parents=True, exist_ok=True)
67
+ for part in PARTS:
68
+ _extract(Path(tmp) / f"{part}.tar.gz", target, part)
69
+ print(f"Fetched {challenge} (version {info['version']}) into {target}/. Start with {target / 'README.txt'}.")
70
+ return target
71
+
72
+ def check(self, challenge, part=None, hidden=False, reference=False, container=True, rebuild=False):
73
+ """Check ./<challenge>/ locally with scoring/score.py on its data (artifact/data/ or data/). Returns the score.
74
+
75
+ part="method" calls score.py's method(), part="artifact" its artifact() and part=None its score(), which
76
+ checks the method first and then the artifact. It runs in the container raid-<challenge>, built like the
77
+ backend's sandbox with the challenge's setup.sh (Docker, else udocker) and rebuilt when setup.sh changes or
78
+ rebuild=True; container=False runs it on this machine instead.
79
+
80
+ For challenge authors: hidden=True scores on hidden/, reference=True scores the files
81
+ in reference/ in place of the student files. Raises RaidError when scoring stops without a score.
82
+ """
83
+ if part not in CHECKS:
84
+ raise RaidError(f"Invalid check {part!r}, use method or artifact.")
85
+ target = (self.path / _check(challenge)).resolve()
86
+ script = target / "scoring" / "score.py"
87
+ dataset = target / "hidden" if hidden else _data(target)
88
+ solution = target / "reference" if reference else target
89
+ for path in (script, dataset, solution):
90
+ if not path.exists():
91
+ raise RaidError(f"{path} not found." + (" Fetch the challenge first." if path == script else ""))
92
+ function = part or "score"
93
+ defined = _functions(script)
94
+ if part and defined is not None and part not in defined:
95
+ raise RaidError(f"{challenge} has no separate {part} check (scoring/score.py has no {part}()). "
96
+ f"Run task check {challenge}.")
97
+ files = "reference/" if reference else "your files"
98
+ what = f"{challenge} {part}" if part else challenge
99
+ here = str(Path.cwd().resolve()) + os.sep # shown paths are relative, e.g. self-assessment/solution.c
100
+ shown = f"{dataset.relative_to(target).as_posix()}/"
101
+ if container:
102
+ from . import _container
103
+ kind = _container.engine()
104
+ if kind is None:
105
+ raise RaidError("task check needs Docker or udocker (pip install udocker) to build the container "
106
+ f"raid-{challenge}. Pass --no-container to check on this machine instead.")
107
+ box = _container.Container(challenge, target, kind)
108
+ print(f"Checking {what}: {files} on {shown} in {box.name} ({kind})")
109
+ try:
110
+ box.ensure(rebuild, status=lambda text: print(_indent(text, here)))
111
+ except _container.ContainerError as e:
112
+ raise RaidError(str(e)) from None
113
+ inside = (str(target) + os.sep).replace(here, "") # /job/ in the container is the challenge directory
114
+ with _Indented(here, {"/job/": inside}) as out:
115
+ result = box.call(function, dataset, solution, out.write)
116
+ result["traceback"] = result.get("traceback", "").replace("/job/", inside)
117
+ else:
118
+ print(f"Checking {what}: {files} on {shown}")
119
+ result = _call_here(function, script, dataset, solution, target, here)
120
+ if "exit" in result:
121
+ raise RaidError("No score, scoring stopped:\n" + _indent(str(result["exit"]).strip(), here))
122
+ if "error" in result:
123
+ raise RaidError(f"No score, scoring/score.py raised {result['error']}:\n" + _indent(result["traceback"], here)
124
+ + ("\nInstall the packages the challenge needs, see its setup.sh."
125
+ if result["error"] == "ModuleNotFoundError" and not container else ""))
126
+ value = result["value"]
127
+ print(f"\n{_bold(f'{CHECKS[part]}: {value:.4f}')}")
128
+ return value
129
+
130
+ def submit(self, challenge):
131
+ """Pack ./<challenge>/ (see _pack) and submit it for scoring. Returns the submission id."""
132
+ target = self.path / _check(challenge)
133
+ if not target.is_dir():
134
+ raise RaidError(f"{target}/ not found. Fetch the challenge first.")
135
+ required = self._json(f"/api/challenges/{challenge}")["required_files"]
136
+ missing = [f for f in required if not (target / f).is_file()]
137
+ if missing:
138
+ raise RaidError(f"Missing required files in {target}/: " + ", ".join(missing))
139
+ with tempfile.TemporaryDirectory() as tmp:
140
+ archive = Path(tmp) / "submission.tar.gz"
141
+ _status(f"Packing {target}/ ...")
142
+ _pack(target, archive)
143
+ size = archive.stat().st_size
144
+ with open(archive, "rb") as f, _Progress("Uploading", size) as bar, self._request(
145
+ "POST", f"/api/submit/{challenge}", data=bar.reader(f), timeout=900,
146
+ headers={"Content-Type": "application/gzip", "Content-Length": str(size)}) as r:
147
+ result = json.load(r)
148
+ print("Job submitted!\nRun task-show to see the status of your jobs.")
149
+ return result["id"]
150
+
151
+ def push(self, directory):
152
+ """Admins: upload a challenge directory (everything but reference/) as a new, active version.
153
+
154
+ Its challenge.yaml supplies the name, the version and the metadata (title, wave, baseline, badge_score,
155
+ badge_points); keys it leaves out keep their current values. Returns the server's summary."""
156
+ target = Path(directory)
157
+ missing = [p for p in ("challenge.yaml", "scoring", "hidden") if not (target / p).exists()]
158
+ if not (target / "data").is_dir() and not (target / ARTIFACT_DATA).is_dir():
159
+ missing.append(f"{ARTIFACT_DATA.as_posix()} (or data)")
160
+ if missing:
161
+ raise RaidError(f"Missing in {target}/: " + ", ".join(missing))
162
+ with tempfile.TemporaryDirectory() as tmp:
163
+ archive = Path(tmp) / "challenge.tar.gz"
164
+ _status(f"Packing {target}/ ...")
165
+ _pack(target, archive, own={"reference"})
166
+ size = archive.stat().st_size
167
+ with open(archive, "rb") as f, _Progress("Uploading", size) as bar, self._request(
168
+ "POST", "/api/admin/push", data=bar.reader(f), timeout=3600,
169
+ headers={"Content-Type": "application/gzip", "Content-Length": str(size)}) as r:
170
+ result = json.load(r)
171
+ wave = f"wave {result['wave']}" if result["wave"] else "the self-assessment"
172
+ print(f"Pushed {result['name']} version {result['version']} ({size / 1e6:.2f} MB) to {wave}"
173
+ + (", now active." if result["active"] else "."))
174
+ return result
175
+
176
+ def wait(self, submission_id, poll=5):
177
+ """Wait until a submission is scored, print its log and return its details."""
178
+ print("Waiting for the scoring (Ctrl-C stops waiting, the scoring continues) ...", flush=True)
179
+ while True:
180
+ sub = self._json(f"/api/submissions/{int(submission_id)}")
181
+ if sub["status"] in ("success", "failed"):
182
+ break
183
+ time.sleep(poll)
184
+ print(sub["log"] or "No log output.")
185
+ print(f"Submission {sub['id']} (version {sub['version']}): {sub['status']}"
186
+ + ("" if sub["score"] is None else f", score {sub['score']:.4f}"))
187
+ return sub
188
+
189
+ def status(self, challenge=None):
190
+ """Print your jobs (submissions, newest first) and return them as a list of dicts."""
191
+ query = f"?challenge={_check(challenge)}" if challenge else ""
192
+ result = self._json(f"/api/submissions{query}")
193
+ subs = result["submissions"]
194
+ print(f"{'JOB':>6} {'CHALLENGE':<20} {'VERSION':<8} {'STATE':<8} {'SCORE':>10} SUBMITTED")
195
+ for s in subs:
196
+ score = "" if s["score"] is None else f"{s['score']:.4f}"
197
+ print(f"{s['id']:>6} {s['challenge']:<20} {s['version']:<8} {s['status']:<8} {score:>10} {s['submitted_at'][:19]}")
198
+ if not subs:
199
+ print("No jobs yet.")
200
+ else:
201
+ print("Run task-log <job> to see the log of a finished job.")
202
+ limits = result.get("daily_limits") or []
203
+ if limits:
204
+ print("\nDaily submission limits (in competition mode per team, reset at 00:00 UTC):")
205
+ for d in limits:
206
+ left = max(d["submissions_per_day"] - d["submitted_today"], 0)
207
+ print(f" {d['challenge']:<20} {left} of {d['submissions_per_day']} left today")
208
+ return subs
209
+
210
+ def log(self, submission_id):
211
+ """Return the scoring log of one of your finished submissions: the output and errors of the scoring run."""
212
+ if not str(submission_id).isdigit():
213
+ raise RaidError(f"Invalid job id {submission_id!r}. task-show lists your jobs.")
214
+ sub = self._json(f"/api/submissions/{submission_id}")
215
+ if sub["status"] in ("pending", "running"):
216
+ raise RaidError(f"Job {sub['id']} is {sub['status']}. Its log is available once it has finished.")
217
+ return sub["log"] or "No log output."
218
+
219
+ def _request(self, method, endpoint, data=None, headers=None, timeout=60):
220
+ if not self.api_key:
221
+ raise RaidError("No API key. Pass --api-key or set RAID_KEY.")
222
+ req = urllib.request.Request(self.url + endpoint, data=data, method=method, headers={
223
+ "Authorization": f"Bearer {self.api_key}", "User-Agent": f"raid/{__version__}", **(headers or {})})
224
+ try:
225
+ return urllib.request.urlopen(req, timeout=timeout)
226
+ except urllib.error.HTTPError as e:
227
+ try:
228
+ message = json.load(e).get("error")
229
+ except (ValueError, AttributeError):
230
+ message = None
231
+ raise RaidError(message or f"{e.code} {e.reason}") from None
232
+ except urllib.error.URLError as e:
233
+ raise RaidError(f"Cannot reach {self.url}: {e.reason}") from None
234
+
235
+ def _json(self, endpoint):
236
+ with self._request("GET", endpoint) as r:
237
+ return json.load(r)
238
+
239
+
240
+ def _status(text):
241
+ """A transient line on stderr, only when it is a terminal."""
242
+ if sys.stderr.isatty():
243
+ sys.stderr.write(f"\r\033[K{text}")
244
+ sys.stderr.flush()
245
+
246
+
247
+ class _Indented:
248
+ """Indent everything printed to stdout and stderr inside the with block by two spaces; paths under here become
249
+ relative, and the keys of paths (e.g. /job/ in the container) become their values."""
250
+
251
+ class _Stream:
252
+ def __init__(self, stream, paths):
253
+ self.stream, self.paths, self.line_start = stream, paths, True
254
+
255
+ def write(self, text):
256
+ for line in text.splitlines(keepends=True):
257
+ if self.line_start and line.strip():
258
+ self.stream.write(" ")
259
+ for old, new in self.paths.items():
260
+ line = line.replace(old, new)
261
+ self.stream.write(line)
262
+ self.line_start = line.endswith(("\n", "\r"))
263
+ self.stream.flush()
264
+ return len(text)
265
+
266
+ def __getattr__(self, name):
267
+ return getattr(self.stream, name)
268
+
269
+ def __init__(self, here=None, paths=None):
270
+ self.paths = {here: ""} if here else {}
271
+ self.paths.update(paths or {})
272
+
273
+ def __enter__(self):
274
+ self.saved = sys.stdout, sys.stderr
275
+ sys.stdout, sys.stderr = self._Stream(sys.stdout, self.paths), self._Stream(sys.stderr, self.paths)
276
+ return sys.stdout
277
+
278
+ def __exit__(self, *exc):
279
+ for stream in (sys.stdout, sys.stderr):
280
+ stream.flush()
281
+ sys.stdout, sys.stderr = self.saved
282
+
283
+
284
+ def _indent(text, here):
285
+ return "\n".join(" " + line.replace(here, "") if line.strip() else line for line in text.splitlines())
286
+
287
+
288
+ def _traceback(error, target):
289
+ """The traceback of an error in score.py, only with the frames of the challenge directory (score.py, solution)."""
290
+ import traceback
291
+ frames = [f for f in traceback.extract_tb(error.__traceback__)
292
+ if target in Path(f.filename).resolve().parents]
293
+ lines = traceback.format_list(frames) + traceback.format_exception_only(error)
294
+ return "".join(lines).rstrip()
295
+
296
+
297
+ def _bold(text):
298
+ return f"\033[1m{text}\033[0m" if sys.stdout.isatty() and not os.environ.get("NO_COLOR") else text
299
+
300
+
301
+ class _Progress:
302
+ """A one-line progress bar on stderr, only when it is a terminal. Without a known total it counts bytes."""
303
+
304
+ def __init__(self, label, total=None):
305
+ self.label, self.total, self.done, self.shown = label, int(total) if total else None, 0, 0.0
306
+ self.tty = sys.stderr.isatty()
307
+
308
+ def __enter__(self):
309
+ self._draw(force=True)
310
+ return self
311
+
312
+ def __exit__(self, *exc):
313
+ if self.tty:
314
+ self._draw(force=True)
315
+ sys.stderr.write("\n" if exc[0] is None else "\r\033[K")
316
+ sys.stderr.flush()
317
+
318
+ def update(self, n):
319
+ self.done += n
320
+ self._draw()
321
+
322
+ def reader(self, f):
323
+ """f with a read() that advances the bar, for uploads."""
324
+ bar = self
325
+
326
+ class Reader:
327
+ def read(self, n=-1):
328
+ chunk = f.read(n)
329
+ bar.update(len(chunk))
330
+ return chunk
331
+ return Reader()
332
+
333
+ def _draw(self, force=False):
334
+ now = time.monotonic()
335
+ if not self.tty or (not force and now - self.shown < 0.1):
336
+ return
337
+ self.shown = now
338
+ if self.total:
339
+ frac = min(self.done / self.total, 1)
340
+ line = f"{self.label} [{'#' * int(frac * 30):<30}] {frac:4.0%} {_size(self.done)} of {_size(self.total)}"
341
+ else:
342
+ line = f"{self.label} {_size(self.done)}"
343
+ sys.stderr.write(f"\r\033[K{line}")
344
+ sys.stderr.flush()
345
+
346
+
347
+ def _size(n):
348
+ return f"{n / 1e6:.1f} MB" if n >= 1e6 else f"{n / 1e3:.0f} kB"
349
+
350
+
351
+ def _data(target):
352
+ """The challenge's data: artifact/data/ in challenges split into artifact/ and method/, else data/."""
353
+ return target / ARTIFACT_DATA if (target / ARTIFACT_DATA).is_dir() else target / "data"
354
+
355
+
356
+ def _functions(script):
357
+ """The names of the top-level functions of score.py, None if it cannot be parsed (running it shows why)."""
358
+ try:
359
+ tree = ast.parse(script.read_bytes())
360
+ except (SyntaxError, ValueError):
361
+ return None
362
+ return {n.name for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
363
+
364
+
365
+ def _call_here(function, script, dataset, solution, target, here):
366
+ """Call function of score.py on dataset in this process, as the container's runner does. Returns its result dict."""
367
+ paths = [str(script.parent), str(solution)] # score.py imports the solution, e.g. `from solution import md5`
368
+ sys.path[:0] = paths
369
+ os.environ["RAID_SOLUTION"] = str(solution) # where score.py finds files that are not imported, e.g. solution.c
370
+ try:
371
+ with _Indented(here): # score.py's own output, set off from the result
372
+ spec = importlib.util.spec_from_file_location("score", script)
373
+ module = importlib.util.module_from_spec(spec)
374
+ spec.loader.exec_module(module)
375
+ value = getattr(module, function)(dataset)
376
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
377
+ return {"error": "TypeError", "traceback": f"{function}() must return a number, not {value!r}"}
378
+ return {"value": float(value)}
379
+ except SystemExit as e: # score.py stops with sys.exit("why"), e.g. when the solution does not compile
380
+ return {"exit": e.code if isinstance(e.code, str) else f"exit code {e.code}"}
381
+ except Exception as e:
382
+ return {"error": type(e).__name__, "traceback": _traceback(e, target)}
383
+ finally:
384
+ del os.environ["RAID_SOLUTION"]
385
+ for p in paths:
386
+ sys.path.remove(p)
387
+ for name, mod in list(sys.modules.items()): # a later check sees the current solution
388
+ if target in Path(getattr(mod, "__file__", None) or "/").resolve().parents:
389
+ del sys.modules[name]
390
+
391
+
392
+ def _check(challenge):
393
+ if not isinstance(challenge, str) or not NAME_RE.match(challenge):
394
+ raise RaidError(f"Invalid challenge name {challenge!r}.")
395
+ return challenge
396
+
397
+
398
+ def _extract(archive, target, part):
399
+ """Extract only regular files and directories below part/, never outside target. source/ maps to target, the
400
+ data archive of a challenge split into artifact/ and method/ holds artifact/data/."""
401
+ root = target.resolve()
402
+ allowed = [part, ARTIFACT_DATA.as_posix()] if part == "data" else [part]
403
+ with tarfile.open(archive, "r:gz") as tar:
404
+ for member in tar:
405
+ name = posixpath.normpath(member.name)
406
+ if not any(name == a or name.startswith(a + "/") for a in allowed) and not (part == "data" and name == "artifact"):
407
+ raise RaidError(f"Unexpected entry {member.name!r} in the {part} archive.")
408
+ if part == "source":
409
+ name = name[len("source/"):] or "."
410
+ dest = (root / name).resolve()
411
+ if dest != root and root not in dest.parents:
412
+ raise RaidError(f"Unsafe entry {member.name!r} in the {part} archive.")
413
+ if member.isdir():
414
+ dest.mkdir(parents=True, exist_ok=True)
415
+ elif member.isfile():
416
+ dest.parent.mkdir(parents=True, exist_ok=True)
417
+ with tar.extractfile(member) as src, open(dest, "wb") as out:
418
+ shutil.copyfileobj(src, out, 1 << 20)
419
+ if member.mode & 0o111:
420
+ dest.chmod(0o755)
421
+
422
+
423
+ def _normalize(info):
424
+ info.uid = info.gid = 0
425
+ info.uname = info.gname = ""
426
+ info.mode = 0o755 if info.isdir() or info.mode & 0o111 else 0o644
427
+ return info
428
+
429
+
430
+ def _ignored(target):
431
+ """Patterns of <challenge>/.raidignore (tar exclude patterns, matched against any path suffix)."""
432
+ path = target / ".raidignore"
433
+ lines = path.read_text().splitlines() if path.is_file() else []
434
+ patterns = [line.strip().rstrip("/") for line in lines if line.strip() and not line.startswith("#")]
435
+ return lambda rel: any(fnmatch.fnmatchcase("/".join(rel.parts[i:]), p) for p in patterns for i in range(len(rel.parts)))
436
+
437
+
438
+ def _pack(target, archive, own=OWN):
439
+ """tar.gz of the challenge directory without the top-level entries in own (by default data/, scoring/,
440
+ hidden/, reference/, challenge.yaml and entrypoint.sh, plus artifact/data/) and the .raidignore patterns.
441
+ Regular files only; caches, VCS data and symlinks are left out."""
442
+ nested = {ARTIFACT_DATA} if own is OWN else set()
443
+ ignored = _ignored(target)
444
+ with tarfile.open(archive, "w:gz") as tar:
445
+ for dirpath, dirnames, filenames in os.walk(target):
446
+ rel = Path(dirpath).relative_to(target)
447
+ top = rel == Path(".")
448
+ dirnames[:] = sorted(d for d in dirnames if d not in SKIP and not (top and d in own) and rel / d not in nested
449
+ and not ignored(rel / d))
450
+ files = sorted(f for f in filenames if f not in SKIP and not (top and f in own) and not ignored(rel / f))
451
+ for name in dirnames + files:
452
+ path = Path(dirpath) / name
453
+ if path.is_symlink():
454
+ print(f"Skipping symlink {path}")
455
+ continue
456
+ if path.is_dir() or path.is_file():
457
+ tar.add(path, arcname=(rel / name).as_posix(), recursive=False, filter=_normalize)
@@ -0,0 +1,177 @@
1
+ """The local container raid-<challenge> in which task-check runs scoring/score.py, built like the backend's sandbox.
2
+
3
+ With Docker it is an image on the backend's base (Debian trixie slim with python3), the challenge's Debian packages
4
+ and the challenge's setup.sh, run as root during the build. With only udocker it is a udocker container of
5
+ debian:trixie in which the same is installed. Either is built again only when setup.sh or the packages change: the
6
+ image's label raid.setup (Docker) or the container's /etc/raid-setup.sha256 (udocker) holds their hash.
7
+ """
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ from pathlib import Path
17
+
18
+ DOCKER_BASE = "debian:trixie-slim" # the base of the backend's sandbox (raid-scoring-service runner/)
19
+ UDOCKER_BASE = "debian:trixie"
20
+ RECIPE = "1" # part of the hash: a new recipe rebuilds every container
21
+ # Trusted directories first, as on the backend: setup.sh's commands in /usr/local come last.
22
+ PATH = "/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/sbin:/usr/local/bin"
23
+ PASSED_ENV = re.compile(r"^(RAID_LLM_\w+|https?_proxy|no_proxy|HTTPS?_PROXY|NO_PROXY)$") # handed into the container
24
+ RUNNER = Path(__file__).with_name("_runner.py")
25
+
26
+
27
+ class ContainerError(Exception):
28
+ pass
29
+
30
+
31
+ def packages(target):
32
+ """The challenge's Debian packages: scoring/packages.txt (from the server) or, for challenge authors,
33
+ requirements in challenge.yaml."""
34
+ listed = target / "scoring" / "packages.txt"
35
+ if listed.is_file():
36
+ return listed.read_text().split()
37
+ meta = target / "challenge.yaml"
38
+ return _requirements(meta.read_text()) if meta.is_file() else []
39
+
40
+
41
+ def _requirements(text):
42
+ """The requirements list of challenge.yaml, as a block (- gcc) or a flow sequence ([gcc, make])."""
43
+ lines = [line.split("#", 1)[0].rstrip() for line in text.splitlines()]
44
+ for i, line in enumerate(lines):
45
+ m = re.match(r"^requirements:\s*(.*)$", line)
46
+ if not m:
47
+ continue
48
+ if m.group(1).startswith("["):
49
+ flow = " ".join([m.group(1)] + lines[i + 1:])
50
+ return [p.strip().strip("'\"") for p in flow[1:flow.index("]")].split(",") if p.strip()]
51
+ found = []
52
+ for item in lines[i + 1:]:
53
+ if item and not item[0].isspace():
54
+ break
55
+ if item.strip().startswith("-"):
56
+ found.append(item.strip()[1:].strip().strip("'\""))
57
+ return found
58
+ return []
59
+
60
+
61
+ def engine():
62
+ """docker when it is installed and its daemon answers, else udocker when it is installed, else None."""
63
+ if shutil.which("docker"):
64
+ try:
65
+ if subprocess.run(["docker", "info"], capture_output=True, timeout=30).returncode == 0:
66
+ return "docker"
67
+ except subprocess.TimeoutExpired:
68
+ pass
69
+ return "udocker" if shutil.which("udocker") else None
70
+
71
+
72
+ class Container:
73
+ def __init__(self, challenge, target, kind):
74
+ self.name, self.target, self.kind = f"raid-{challenge}", target, kind
75
+ self.packages = packages(target)
76
+ setup = target / "setup.sh"
77
+ self.setup = setup.read_bytes() if setup.is_file() else b""
78
+ base = DOCKER_BASE if kind == "docker" else UDOCKER_BASE
79
+ self.hash = hashlib.sha256(f"{RECIPE}\0{base}\0{' '.join(self.packages)}\0".encode() + self.setup).hexdigest()
80
+
81
+ # ---------- building ----------
82
+
83
+ def ensure(self, rebuild=False, status=print):
84
+ """Build the container unless it exists with the current setup.sh and packages."""
85
+ if not rebuild and self._built_hash() == self.hash:
86
+ return
87
+ status(f"Building {self.name} with {'setup.sh' if self.setup.strip() else 'no setup.sh'}"
88
+ f"{' and ' + ' '.join(self.packages) if self.packages else ''} (once, and again when they change) ...")
89
+ with tempfile.TemporaryDirectory() as tmp:
90
+ Path(tmp, "setup.sh").write_bytes(self.setup)
91
+ if self.kind == "docker":
92
+ self._build_docker(Path(tmp))
93
+ else:
94
+ self._build_udocker(Path(tmp))
95
+
96
+ def _install(self):
97
+ return ("apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "
98
+ f"python3 ca-certificates {' '.join(self.packages)}")
99
+
100
+ def _built_hash(self):
101
+ if self.kind == "docker":
102
+ found = subprocess.run(["docker", "image", "inspect", "-f", '{{index .Config.Labels "raid.setup"}}',
103
+ self.name], capture_output=True, text=True)
104
+ return found.stdout.strip() if found.returncode == 0 else None
105
+ found = subprocess.run(self._udocker("inspect", "-p", self.name), capture_output=True, text=True)
106
+ if found.returncode:
107
+ return None
108
+ stamp = Path(found.stdout.strip().splitlines()[-1]) / "etc" / "raid-setup.sha256"
109
+ return stamp.read_text().strip() if stamp.is_file() else ""
110
+
111
+ def _build_docker(self, context):
112
+ dockerfile = (f"FROM {DOCKER_BASE} AS trusted\n"
113
+ f"RUN {self._install()} && rm -rf /var/lib/apt/lists/*\n")
114
+ if self.setup.strip(): # as on the backend: afterwards the trusted image is copied back over setup.sh's changes
115
+ dockerfile += ("FROM trusted\n"
116
+ "COPY setup.sh /tmp/raid-setup/setup.sh\n"
117
+ "RUN cd /tmp/raid-setup && DEBIAN_FRONTEND=noninteractive bash setup.sh\n"
118
+ "COPY --from=trusted / /\n"
119
+ "RUN rm -rf /tmp/raid-setup\n")
120
+ (context / "Dockerfile").write_text(dockerfile)
121
+ self._step(["docker", "build", "-t", self.name, "--label", f"raid.setup={self.hash}", str(context)], setup=True)
122
+
123
+ def _build_udocker(self, context):
124
+ if self._built_hash() is not None:
125
+ self._step(self._udocker("rm", self.name))
126
+ if subprocess.run(self._udocker("inspect", UDOCKER_BASE), capture_output=True).returncode:
127
+ self._step(self._udocker("pull", UDOCKER_BASE))
128
+ self._step(self._udocker("create", f"--name={self.name}", UDOCKER_BASE))
129
+ script = (f"set -e; {self._install()}; "
130
+ "cd /raid-setup && DEBIAN_FRONTEND=noninteractive bash setup.sh; "
131
+ f"echo {self.hash} > /etc/raid-setup.sha256")
132
+ try:
133
+ self._step(self._udocker("run", "--user=root", f"-v={context}:/raid-setup", self.name,
134
+ "/bin/bash", "-c", script), setup=True)
135
+ except ContainerError:
136
+ subprocess.run(self._udocker("rm", self.name), capture_output=True) # never reused half-built
137
+ raise
138
+
139
+ def _step(self, cmd, setup=False):
140
+ """Run a build command; on failure raise ContainerError with the end of its output. setup: the command
141
+ installs the packages and runs setup.sh."""
142
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
143
+ if proc.returncode:
144
+ tail = "\n".join(proc.stdout.decode(errors="replace").rstrip().splitlines()[-40:])
145
+ step = " ".join(c for c in cmd[:4] if c not in ("-q", "--allow-root"))
146
+ raise ContainerError(f"Building {self.name} failed in `{step} ...`:\n{tail}"
147
+ + ("\nCheck setup.sh: it runs as root in the image build, with internet access." if setup else ""))
148
+
149
+ def _udocker(self, *args):
150
+ return ["udocker", "-q", *(["--allow-root"] if hasattr(os, "geteuid") and os.geteuid() == 0 else []), *args]
151
+
152
+ # ---------- running ----------
153
+
154
+ def call(self, function, dataset, solution, write=None):
155
+ """Run function (score, method or artifact) of scoring/score.py on dataset in the container, with the
156
+ challenge directory at /job. Output goes to write line by line. Returns the runner's result dict."""
157
+ write = write or sys.stdout.write
158
+ inside = lambda p: "/job/" + p.relative_to(self.target).as_posix() if p != self.target else "/job" # noqa: E731
159
+ env = {"HOME": "/tmp", "PATH": PATH, "RAID_SOLUTION": inside(solution), "PYTHONDONTWRITEBYTECODE": "1",
160
+ **{k: v for k, v in os.environ.items() if PASSED_ENV.match(k)}}
161
+ with tempfile.TemporaryDirectory() as tmp:
162
+ shutil.copy(RUNNER, Path(tmp, "runner.py"))
163
+ command = ["python3", "-IS", "/raid-check/runner.py", function, inside(dataset), "/raid-check/result.json"]
164
+ if self.kind == "docker":
165
+ user = ["--user", f"{os.getuid()}:{os.getgid()}"] if hasattr(os, "getuid") else []
166
+ cmd = ["docker", "run", "--rm", "--init", *user, "-v", f"{self.target}:/job", "-v", f"{tmp}:/raid-check",
167
+ "-w", "/job", *[f"--env={k}={v}" for k, v in env.items()], self.name, *command]
168
+ else:
169
+ cmd = self._udocker("run", f"-v={self.target}:/job", f"-v={tmp}:/raid-check", "--workdir=/job",
170
+ *[f"--env={k}={v}" for k, v in env.items()], self.name, *command)
171
+ with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL) as proc:
172
+ for line in iter(proc.stdout.readline, b""):
173
+ write(line.decode(errors="replace"))
174
+ result = Path(tmp, "result.json")
175
+ if not result.is_file():
176
+ return {"exit": f"the container {self.name} exited with code {proc.returncode} before score.py finished"}
177
+ return json.loads(result.read_text())
@@ -0,0 +1,46 @@
1
+ """Runs inside the raid-<challenge> container: python3 -IS _runner.py <function> <dataset> <result.json>
2
+
3
+ Calls <function> (score, method or artifact) of scoring/score.py in the working directory (the challenge directory)
4
+ with the dataset path and writes {"value": ...}, {"exit": ...} or {"error": ..., "traceback": ...} to result.json.
5
+ Like the backend, score.py sees only Debian's Python packages.
6
+ """
7
+ import importlib.util
8
+ import json
9
+ import os
10
+ import sys
11
+ import traceback
12
+ from pathlib import Path
13
+
14
+ DEBIAN_PACKAGES = "/usr/lib/python3/dist-packages"
15
+
16
+
17
+ def main(function, dataset, out):
18
+ job = Path.cwd()
19
+ script = job / "scoring" / "score.py"
20
+ solution = os.environ.get("RAID_SOLUTION", str(job))
21
+ sys.dont_write_bytecode = True # no __pycache__/ in the challenge directory
22
+ sys.path.append(DEBIAN_PACKAGES)
23
+ sys.path[:0] = [str(script.parent), solution] # score.py imports the solution, e.g. `from solution import md5`
24
+ try:
25
+ spec = importlib.util.spec_from_file_location("score", script)
26
+ module = importlib.util.module_from_spec(spec)
27
+ spec.loader.exec_module(module)
28
+ value = getattr(module, function)(Path(dataset))
29
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
30
+ result = {"error": "TypeError", "traceback": f"{function}() must return a number, not {value!r}"}
31
+ else:
32
+ result = {"value": float(value)}
33
+ except SystemExit as e: # score.py stops with sys.exit("why"), e.g. when the solution does not compile
34
+ result = {"exit": e.code if isinstance(e.code, str) else f"exit code {e.code}"}
35
+ except BaseException as e:
36
+ frames = [f for f in traceback.extract_tb(e.__traceback__) if job in Path(f.filename).resolve().parents]
37
+ result = {"error": type(e).__name__,
38
+ "traceback": "".join(traceback.format_list(frames) + traceback.format_exception_only(e)).rstrip()}
39
+ sys.stdout.flush()
40
+ sys.stderr.flush()
41
+ with open(out, "w") as f:
42
+ json.dump(result, f)
43
+
44
+
45
+ if __name__ == "__main__":
46
+ main(*sys.argv[1:4])
@@ -5,8 +5,9 @@ import sys
5
5
  from . import Raid, RaidError
6
6
 
7
7
 
8
- def _run(description, action, key=True, options=(), target=("challenge", "challenge name, e.g. self-assessment")):
9
- parser = argparse.ArgumentParser(description=description)
8
+ def _run(description, action, key=True, options=(), target=("challenge", "challenge name, e.g. self-assessment"),
9
+ parser=None):
10
+ parser = parser or argparse.ArgumentParser(description=description)
10
11
  if key:
11
12
  parser.add_argument("--api-key", help="your API key (default: $RAID_KEY)")
12
13
  for flag, text in options:
@@ -26,13 +27,32 @@ def fetch():
26
27
 
27
28
 
28
29
  def check():
29
- _run("Score ./<challenge>/ locally on data/.", lambda raid, a: raid.check(a.challenge, a.hidden, a.reference),
30
- key=False, options=[("--hidden", "challenge authors: score on hidden/ instead of data/"),
31
- ("--reference", "challenge authors: score reference/ instead of the student files")])
30
+ """task check [method|artifact] <challenge>: the method, the artifact or both (first the method)."""
31
+ parser = argparse.ArgumentParser(
32
+ description="Check ./<challenge>/ locally on its data in the container raid-<challenge>, built like our "
33
+ "backend's with the challenge's setup.sh. `task check method <challenge>` runs your method, "
34
+ "`task check artifact <challenge>` scores your artifact, `task check <challenge>` does both.",
35
+ usage="%(prog)s [-h] [options] [method|artifact] challenge")
36
+ parser.add_argument("names", nargs="+", metavar="[method|artifact] challenge", help=argparse.SUPPRESS)
37
+
38
+ def action(raid, a):
39
+ names = a.names
40
+ if len(names) == 2 and names[1] in ("method", "artifact") and names[0] not in ("method", "artifact"):
41
+ names = names[::-1] # task check dawn method
42
+ if len(names) > 2 or len(names) == 2 and names[0] not in ("method", "artifact"):
43
+ parser.error("expected [method|artifact] <challenge>")
44
+ part, challenge = (None, names[0]) if len(names) == 1 else names
45
+ raid.check(challenge, part, a.hidden, a.reference, container=not a.no_container, rebuild=a.rebuild)
46
+
47
+ _run(None, action, key=False, target=None, parser=parser, options=[
48
+ ("--rebuild", "build the container raid-<challenge> again even if setup.sh did not change"),
49
+ ("--no-container", "check on this machine instead of in the container (needs the challenge's packages)"),
50
+ ("--hidden", "challenge authors: score on hidden/ instead of the data"),
51
+ ("--reference", "challenge authors: score reference/ instead of the student files")])
32
52
 
33
53
 
34
54
  def submit():
35
- _run("Submit ./<challenge>/ for scoring (without data/, scoring/ and .raidignore patterns).",
55
+ _run("Submit ./<challenge>/ for scoring (without its data, scoring/ and .raidignore patterns).",
36
56
  lambda raid, a: raid.submit(a.challenge))
37
57
 
38
58
 
@@ -47,7 +67,7 @@ def log():
47
67
 
48
68
  def push():
49
69
  _run("Admins: upload a challenge directory (without reference/) as its new active version. "
50
- "challenge.yaml supplies the name, the version and the metadata of a new challenge.",
70
+ "challenge.yaml supplies the name, the version and the metadata.",
51
71
  lambda raid, a: raid.push(a.directory), target=("directory", "challenge directory, e.g. self-assessment"))
52
72
 
53
73
 
mlsec_raid-0.4.0/PKG-INFO DELETED
@@ -1,29 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: mlsec-raid
3
- Version: 0.4.0
4
- Summary: Client for the RAID competition system
5
- Requires-Python: >=3.9
6
- Description-Content-Type: text/markdown
7
-
8
- # raid
9
-
10
- Python client for the RAID competition system.
11
-
12
- ```bash
13
- pip install mlsec-raid # or: uv tool install mlsec-raid
14
- export RAID_KEY=<your-api-key> # or pass --api-key <your-api-key>
15
-
16
- task-fetch <challenge> # downloads the challenge into ./<challenge>/
17
- task-check <challenge> # scores it locally with scoring/score.py on data/
18
- task-submit <challenge> # submits ./<challenge>/ without data/, scoring/ and .raidignore patterns
19
- task-show # lists your jobs with their state (pending, running, success, failed) and score
20
- task-log <job> # prints the log of a finished job, including the errors of your solution
21
- ```
22
-
23
- Every command also works as `task <command>`, e.g. `task submit <challenge>`.
24
-
25
- The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
26
-
27
- Challenge authors: `task-check <challenge> --hidden --reference` scores `reference/` on `hidden/`. Admins upload a challenge directory with `task-push <directory>` and their own API key (created in the admin console): the directory's `challenge.yaml` sets the name and version (and the wave, baseline and badge of a new challenge), and the upload becomes the active version.
28
-
29
- Development: `uv sync` sets up the environment, `uv build` builds the package. Set `RAID_URL` to use another server. Release: bump `version` in `pyproject.toml`, then `uv build && uv publish` (PyPI token in `UV_PUBLISH_TOKEN`).
@@ -1,22 +0,0 @@
1
- # raid
2
-
3
- Python client for the RAID competition system.
4
-
5
- ```bash
6
- pip install mlsec-raid # or: uv tool install mlsec-raid
7
- export RAID_KEY=<your-api-key> # or pass --api-key <your-api-key>
8
-
9
- task-fetch <challenge> # downloads the challenge into ./<challenge>/
10
- task-check <challenge> # scores it locally with scoring/score.py on data/
11
- task-submit <challenge> # submits ./<challenge>/ without data/, scoring/ and .raidignore patterns
12
- task-show # lists your jobs with their state (pending, running, success, failed) and score
13
- task-log <job> # prints the log of a finished job, including the errors of your solution
14
- ```
15
-
16
- Every command also works as `task <command>`, e.g. `task submit <challenge>`.
17
-
18
- The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
19
-
20
- Challenge authors: `task-check <challenge> --hidden --reference` scores `reference/` on `hidden/`. Admins upload a challenge directory with `task-push <directory>` and their own API key (created in the admin console): the directory's `challenge.yaml` sets the name and version (and the wave, baseline and badge of a new challenge), and the upload becomes the active version.
21
-
22
- Development: `uv sync` sets up the environment, `uv build` builds the package. Set `RAID_URL` to use another server. Release: bump `version` in `pyproject.toml`, then `uv build && uv publish` (PyPI token in `UV_PUBLISH_TOKEN`).
@@ -1,256 +0,0 @@
1
- """Client for the RAID competition system: fetch challenges, check and submit solutions."""
2
- import fnmatch
3
- import importlib.util
4
- import json
5
- import os
6
- import posixpath
7
- import re
8
- import shutil
9
- import sys
10
- import tarfile
11
- import tempfile
12
- import time
13
- import urllib.error
14
- import urllib.request
15
- from pathlib import Path
16
-
17
- __all__ = ["Raid", "RaidError"]
18
- __version__ = "0.4.0"
19
-
20
- DEFAULT_URL = "https://raid.mlsec.tu-berlin.de"
21
- PARTS = ("source", "data", "scoring") # source/ is unpacked into the challenge directory itself
22
- OWN = {"data", "scoring", "hidden", "reference", "challenge.yaml"} # parts of the challenge directory that are never submitted
23
- SKIP = {"__pycache__", ".git", ".ipynb_checkpoints", ".venv", "venv", ".DS_Store"}
24
- NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
25
-
26
-
27
- class RaidError(Exception):
28
- pass
29
-
30
-
31
- class Raid:
32
- """raid = Raid(api_key); print(raid.howto())"""
33
-
34
- def __init__(self, api_key=None, url=None, path="."):
35
- self.api_key = (api_key or os.environ.get("RAID_KEY") or "").strip()
36
- self.url = (url or os.environ.get("RAID_URL") or DEFAULT_URL).rstrip("/")
37
- self.path = Path(path)
38
-
39
- def howto(self):
40
- """Explain the workflow."""
41
- return self._json("/api/howto")["text"]
42
-
43
- def fetch(self, challenge, force=False):
44
- """Download a challenge into ./<challenge>/: its files, data/ and scoring/.
45
-
46
- An existing directory is left untouched unless force=True, which overwrites the challenge files.
47
- """
48
- target = self.path / _check(challenge)
49
- if target.exists() and not force:
50
- print(f"{target}/ already exists. Fetch with force=True (task-fetch --force) to download it again "
51
- "(this overwrites the challenge files).")
52
- return target
53
- info = self._json(f"/api/challenges/{challenge}")
54
- with tempfile.TemporaryDirectory() as tmp:
55
- for part in PARTS:
56
- with self._request("GET", f"/api/challenges/{challenge}/{part}.tar.gz", timeout=300) as r, \
57
- open(Path(tmp) / f"{part}.tar.gz", "wb") as f:
58
- shutil.copyfileobj(r, f, 1 << 20)
59
- target.mkdir(parents=True, exist_ok=True)
60
- for part in PARTS:
61
- _extract(Path(tmp) / f"{part}.tar.gz", target, part)
62
- print(f"Fetched {challenge} (version {info['version']}) into {target}/. Start with {target / 'README.txt'}.")
63
- return target
64
-
65
- def check(self, challenge, hidden=False, reference=False):
66
- """Score ./<challenge>/ locally: scoring/score.py's score() on data/. Returns the score.
67
-
68
- For challenge authors: hidden=True scores on hidden/, reference=True scores the files
69
- in reference/ in place of the student files.
70
- """
71
- target = (self.path / _check(challenge)).resolve()
72
- script = target / "scoring" / "score.py"
73
- dataset = target / ("hidden" if hidden else "data")
74
- solution = target / "reference" if reference else target
75
- for path in (script, dataset, solution):
76
- if not path.exists():
77
- raise RaidError(f"{path} not found." + (" Fetch the challenge first." if path == script else ""))
78
- paths = [str(script.parent), str(solution)] # score.py imports the solution, e.g. `from solution import md5`
79
- sys.path[:0] = paths
80
- os.environ["RAID_SOLUTION"] = str(solution) # where score.py finds files that are not imported, e.g. solution.c
81
- try:
82
- spec = importlib.util.spec_from_file_location("score", script)
83
- module = importlib.util.module_from_spec(spec)
84
- spec.loader.exec_module(module)
85
- value = module.score(dataset)
86
- finally:
87
- del os.environ["RAID_SOLUTION"]
88
- for p in paths:
89
- sys.path.remove(p)
90
- for name, mod in list(sys.modules.items()): # a later check sees the current solution
91
- if target in Path(getattr(mod, "__file__", None) or "/").resolve().parents:
92
- del sys.modules[name]
93
- print(f"Score: {value}")
94
- return value
95
-
96
- def submit(self, challenge):
97
- """Pack ./<challenge>/ (see _pack) and submit it for scoring. Returns the submission id."""
98
- target = self.path / _check(challenge)
99
- if not target.is_dir():
100
- raise RaidError(f"{target}/ not found. Fetch the challenge first.")
101
- required = self._json(f"/api/challenges/{challenge}")["required_files"]
102
- missing = [f for f in required if not (target / f).is_file()]
103
- if missing:
104
- raise RaidError(f"Missing required files in {target}/: " + ", ".join(missing))
105
- with tempfile.TemporaryDirectory() as tmp:
106
- archive = Path(tmp) / "submission.tar.gz"
107
- _pack(target, archive)
108
- size = archive.stat().st_size
109
- with open(archive, "rb") as f, self._request(
110
- "POST", f"/api/submit/{challenge}", data=f, timeout=900,
111
- headers={"Content-Type": "application/gzip", "Content-Length": str(size)}) as r:
112
- result = json.load(r)
113
- print("Job submitted!\nRun task-show to see the status of your jobs.")
114
- return result["id"]
115
-
116
- def push(self, directory):
117
- """Admins: upload a challenge directory (everything but reference/) as a new, active version.
118
-
119
- Its challenge.yaml supplies the name, the version and, for a new challenge, the metadata (title, wave,
120
- baseline, badge_score, badge_points; later changes happen in the admin console). Returns the server's summary."""
121
- target = Path(directory)
122
- missing = [p for p in ("challenge.yaml", "data", "scoring", "hidden") if not (target / p).exists()]
123
- if missing:
124
- raise RaidError(f"Missing in {target}/: " + ", ".join(missing))
125
- with tempfile.TemporaryDirectory() as tmp:
126
- archive = Path(tmp) / "challenge.tar.gz"
127
- _pack(target, archive, own={"reference"})
128
- size = archive.stat().st_size
129
- with open(archive, "rb") as f, self._request(
130
- "POST", "/api/admin/push", data=f, timeout=3600,
131
- headers={"Content-Type": "application/gzip", "Content-Length": str(size)}) as r:
132
- result = json.load(r)
133
- wave = f"wave {result['wave']}" if result["wave"] else "the self-assessment"
134
- print(f"Pushed {result['name']} version {result['version']} ({size / 1e6:.2f} MB) to {wave}"
135
- + (", now active." if result["active"] else "."))
136
- return result
137
-
138
- def wait(self, submission_id, poll=5):
139
- """Wait until a submission is scored, print its log and return its details."""
140
- print("Waiting for the scoring (Ctrl-C stops waiting, the scoring continues) ...", flush=True)
141
- while True:
142
- sub = self._json(f"/api/submissions/{int(submission_id)}")
143
- if sub["status"] in ("success", "failed"):
144
- break
145
- time.sleep(poll)
146
- print(sub["log"] or "No log output.")
147
- print(f"Submission {sub['id']} (version {sub['version']}): {sub['status']}"
148
- + ("" if sub["score"] is None else f", score {sub['score']:.4f}"))
149
- return sub
150
-
151
- def status(self, challenge=None):
152
- """Print your jobs (submissions, newest first) and return them as a list of dicts."""
153
- query = f"?challenge={_check(challenge)}" if challenge else ""
154
- subs = self._json(f"/api/submissions{query}")["submissions"]
155
- print(f"{'JOB':>6} {'CHALLENGE':<20} {'VERSION':<8} {'STATE':<8} {'SCORE':>10} SUBMITTED")
156
- for s in subs:
157
- score = "" if s["score"] is None else f"{s['score']:.4f}"
158
- print(f"{s['id']:>6} {s['challenge']:<20} {s['version']:<8} {s['status']:<8} {score:>10} {s['submitted_at'][:19]}")
159
- if not subs:
160
- print("No jobs yet.")
161
- else:
162
- print("Run task-log <job> to see the log of a finished job.")
163
- return subs
164
-
165
- def log(self, submission_id):
166
- """Return the scoring log of one of your finished submissions: the output and errors of the scoring run."""
167
- if not str(submission_id).isdigit():
168
- raise RaidError(f"Invalid job id {submission_id!r}. task-show lists your jobs.")
169
- sub = self._json(f"/api/submissions/{submission_id}")
170
- if sub["status"] in ("pending", "running"):
171
- raise RaidError(f"Job {sub['id']} is {sub['status']}. Its log is available once it has finished.")
172
- return sub["log"] or "No log output."
173
-
174
- def _request(self, method, endpoint, data=None, headers=None, timeout=60):
175
- if not self.api_key:
176
- raise RaidError("No API key. Pass --api-key or set RAID_KEY.")
177
- req = urllib.request.Request(self.url + endpoint, data=data, method=method, headers={
178
- "Authorization": f"Bearer {self.api_key}", "User-Agent": f"raid/{__version__}", **(headers or {})})
179
- try:
180
- return urllib.request.urlopen(req, timeout=timeout)
181
- except urllib.error.HTTPError as e:
182
- try:
183
- message = json.load(e).get("error")
184
- except (ValueError, AttributeError):
185
- message = None
186
- raise RaidError(message or f"{e.code} {e.reason}") from None
187
- except urllib.error.URLError as e:
188
- raise RaidError(f"Cannot reach {self.url}: {e.reason}") from None
189
-
190
- def _json(self, endpoint):
191
- with self._request("GET", endpoint) as r:
192
- return json.load(r)
193
-
194
-
195
- def _check(challenge):
196
- if not isinstance(challenge, str) or not NAME_RE.match(challenge):
197
- raise RaidError(f"Invalid challenge name {challenge!r}.")
198
- return challenge
199
-
200
-
201
- def _extract(archive, target, part):
202
- """Extract only regular files and directories below part/, never outside target. source/ maps to target."""
203
- root = target.resolve()
204
- with tarfile.open(archive, "r:gz") as tar:
205
- for member in tar:
206
- name = posixpath.normpath(member.name)
207
- if name != part and not name.startswith(part + "/"):
208
- raise RaidError(f"Unexpected entry {member.name!r} in the {part} archive.")
209
- if part == "source":
210
- name = name[len("source/"):] or "."
211
- dest = (root / name).resolve()
212
- if dest != root and root not in dest.parents:
213
- raise RaidError(f"Unsafe entry {member.name!r} in the {part} archive.")
214
- if member.isdir():
215
- dest.mkdir(parents=True, exist_ok=True)
216
- elif member.isfile():
217
- dest.parent.mkdir(parents=True, exist_ok=True)
218
- with tar.extractfile(member) as src, open(dest, "wb") as out:
219
- shutil.copyfileobj(src, out, 1 << 20)
220
- if member.mode & 0o111:
221
- dest.chmod(0o755)
222
-
223
-
224
- def _normalize(info):
225
- info.uid = info.gid = 0
226
- info.uname = info.gname = ""
227
- info.mode = 0o755 if info.isdir() or info.mode & 0o111 else 0o644
228
- return info
229
-
230
-
231
- def _ignored(target):
232
- """Patterns of <challenge>/.raidignore (tar exclude patterns, matched against any path suffix)."""
233
- path = target / ".raidignore"
234
- lines = path.read_text().splitlines() if path.is_file() else []
235
- patterns = [line.strip().rstrip("/") for line in lines if line.strip() and not line.startswith("#")]
236
- return lambda rel: any(fnmatch.fnmatchcase("/".join(rel.parts[i:]), p) for p in patterns for i in range(len(rel.parts)))
237
-
238
-
239
- def _pack(target, archive, own=OWN):
240
- """tar.gz of the challenge directory without the top-level entries in own (by default data/, scoring/,
241
- hidden/, reference/ and challenge.yaml) and the .raidignore patterns. Regular files only; caches,
242
- VCS data and symlinks are left out."""
243
- ignored = _ignored(target)
244
- with tarfile.open(archive, "w:gz") as tar:
245
- for dirpath, dirnames, filenames in os.walk(target):
246
- rel = Path(dirpath).relative_to(target)
247
- top = rel == Path(".")
248
- dirnames[:] = sorted(d for d in dirnames if d not in SKIP and not (top and d in own) and not ignored(rel / d))
249
- files = sorted(f for f in filenames if f not in SKIP and not (top and f in own) and not ignored(rel / f))
250
- for name in dirnames + files:
251
- path = Path(dirpath) / name
252
- if path.is_symlink():
253
- print(f"Skipping symlink {path}")
254
- continue
255
- if path.is_dir() or path.is_file():
256
- tar.add(path, arcname=(rel / name).as_posix(), recursive=False, filter=_normalize)