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.
- mlsec_raid-0.5.0/PKG-INFO +45 -0
- mlsec_raid-0.5.0/README.md +38 -0
- {mlsec_raid-0.4.0 → mlsec_raid-0.5.0}/pyproject.toml +1 -1
- mlsec_raid-0.5.0/src/raid/__init__.py +457 -0
- mlsec_raid-0.5.0/src/raid/_container.py +177 -0
- mlsec_raid-0.5.0/src/raid/_runner.py +46 -0
- {mlsec_raid-0.4.0 → mlsec_raid-0.5.0}/src/raid/cli.py +27 -7
- mlsec_raid-0.4.0/PKG-INFO +0 -29
- mlsec_raid-0.4.0/README.md +0 -22
- mlsec_raid-0.4.0/src/raid/__init__.py +0 -256
|
@@ -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`).
|
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
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`).
|
mlsec_raid-0.4.0/README.md
DELETED
|
@@ -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)
|