mlsec-raid 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mlsec_raid-0.3.0.dist-info/METADATA +25 -0
- mlsec_raid-0.3.0.dist-info/RECORD +6 -0
- mlsec_raid-0.3.0.dist-info/WHEEL +4 -0
- mlsec_raid-0.3.0.dist-info/entry_points.txt +6 -0
- raid/__init__.py +249 -0
- raid/cli.py +46 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mlsec-raid
|
|
3
|
+
Version: 0.3.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, prints score and log
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
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,6 @@
|
|
|
1
|
+
raid/__init__.py,sha256=eAb_oWikTw5MU270aFg1znJz-7F9jox_E-TceLmgQVQ,12158
|
|
2
|
+
raid/cli.py,sha256=n4sjVpQxzoSJC2T48xtt3aCV2tM0iQwd5yqJ6345qOc,1958
|
|
3
|
+
mlsec_raid-0.3.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
4
|
+
mlsec_raid-0.3.0.dist-info/entry_points.txt,sha256=RT8Z02L1qhbbq1WYOKqg4W1wmwXpjI94s5zx1MYBWWI,131
|
|
5
|
+
mlsec_raid-0.3.0.dist-info/METADATA,sha256=ABCca79NCdPz_z0980q5IkRLjJm6V7j6IES7uZuAneY,1452
|
|
6
|
+
mlsec_raid-0.3.0.dist-info/RECORD,,
|
raid/__init__.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
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.3.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(f"Submitted {challenge} ({size / 1e6:.2f} MB) as submission {result['id']} (challenge version {result['version']}).")
|
|
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 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"{'ID':>6} {'CHALLENGE':<20} {'VERSION':<8} {'STATUS':<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 submissions yet.")
|
|
161
|
+
return subs
|
|
162
|
+
|
|
163
|
+
def log(self, submission_id):
|
|
164
|
+
"""Return the scoring log of one of your submissions."""
|
|
165
|
+
return self._json(f"/api/submissions/{int(submission_id)}")["log"]
|
|
166
|
+
|
|
167
|
+
def _request(self, method, endpoint, data=None, headers=None, timeout=60):
|
|
168
|
+
if not self.api_key:
|
|
169
|
+
raise RaidError("No API key. Pass --api-key or set RAID_KEY.")
|
|
170
|
+
req = urllib.request.Request(self.url + endpoint, data=data, method=method, headers={
|
|
171
|
+
"Authorization": f"Bearer {self.api_key}", "User-Agent": f"raid/{__version__}", **(headers or {})})
|
|
172
|
+
try:
|
|
173
|
+
return urllib.request.urlopen(req, timeout=timeout)
|
|
174
|
+
except urllib.error.HTTPError as e:
|
|
175
|
+
try:
|
|
176
|
+
message = json.load(e).get("error")
|
|
177
|
+
except (ValueError, AttributeError):
|
|
178
|
+
message = None
|
|
179
|
+
raise RaidError(message or f"{e.code} {e.reason}") from None
|
|
180
|
+
except urllib.error.URLError as e:
|
|
181
|
+
raise RaidError(f"Cannot reach {self.url}: {e.reason}") from None
|
|
182
|
+
|
|
183
|
+
def _json(self, endpoint):
|
|
184
|
+
with self._request("GET", endpoint) as r:
|
|
185
|
+
return json.load(r)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _check(challenge):
|
|
189
|
+
if not isinstance(challenge, str) or not NAME_RE.match(challenge):
|
|
190
|
+
raise RaidError(f"Invalid challenge name {challenge!r}.")
|
|
191
|
+
return challenge
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _extract(archive, target, part):
|
|
195
|
+
"""Extract only regular files and directories below part/, never outside target. source/ maps to target."""
|
|
196
|
+
root = target.resolve()
|
|
197
|
+
with tarfile.open(archive, "r:gz") as tar:
|
|
198
|
+
for member in tar:
|
|
199
|
+
name = posixpath.normpath(member.name)
|
|
200
|
+
if name != part and not name.startswith(part + "/"):
|
|
201
|
+
raise RaidError(f"Unexpected entry {member.name!r} in the {part} archive.")
|
|
202
|
+
if part == "source":
|
|
203
|
+
name = name[len("source/"):] or "."
|
|
204
|
+
dest = (root / name).resolve()
|
|
205
|
+
if dest != root and root not in dest.parents:
|
|
206
|
+
raise RaidError(f"Unsafe entry {member.name!r} in the {part} archive.")
|
|
207
|
+
if member.isdir():
|
|
208
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
elif member.isfile():
|
|
210
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
with tar.extractfile(member) as src, open(dest, "wb") as out:
|
|
212
|
+
shutil.copyfileobj(src, out, 1 << 20)
|
|
213
|
+
if member.mode & 0o111:
|
|
214
|
+
dest.chmod(0o755)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _normalize(info):
|
|
218
|
+
info.uid = info.gid = 0
|
|
219
|
+
info.uname = info.gname = ""
|
|
220
|
+
info.mode = 0o755 if info.isdir() or info.mode & 0o111 else 0o644
|
|
221
|
+
return info
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _ignored(target):
|
|
225
|
+
"""Patterns of <challenge>/.raidignore (tar exclude patterns, matched against any path suffix)."""
|
|
226
|
+
path = target / ".raidignore"
|
|
227
|
+
lines = path.read_text().splitlines() if path.is_file() else []
|
|
228
|
+
patterns = [line.strip().rstrip("/") for line in lines if line.strip() and not line.startswith("#")]
|
|
229
|
+
return lambda rel: any(fnmatch.fnmatchcase("/".join(rel.parts[i:]), p) for p in patterns for i in range(len(rel.parts)))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _pack(target, archive, own=OWN):
|
|
233
|
+
"""tar.gz of the challenge directory without the top-level entries in own (by default data/, scoring/,
|
|
234
|
+
hidden/, reference/ and challenge.yaml) and the .raidignore patterns. Regular files only; caches,
|
|
235
|
+
VCS data and symlinks are left out."""
|
|
236
|
+
ignored = _ignored(target)
|
|
237
|
+
with tarfile.open(archive, "w:gz") as tar:
|
|
238
|
+
for dirpath, dirnames, filenames in os.walk(target):
|
|
239
|
+
rel = Path(dirpath).relative_to(target)
|
|
240
|
+
top = rel == Path(".")
|
|
241
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP and not (top and d in own) and not ignored(rel / d))
|
|
242
|
+
files = sorted(f for f in filenames if f not in SKIP and not (top and f in own) and not ignored(rel / f))
|
|
243
|
+
for name in dirnames + files:
|
|
244
|
+
path = Path(dirpath) / name
|
|
245
|
+
if path.is_symlink():
|
|
246
|
+
print(f"Skipping symlink {path}")
|
|
247
|
+
continue
|
|
248
|
+
if path.is_dir() or path.is_file():
|
|
249
|
+
tar.add(path, arcname=(rel / name).as_posix(), recursive=False, filter=_normalize)
|
raid/cli.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Command line tools: task-fetch, task-check, task-submit and task-push."""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from . import Raid, RaidError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _run(description, action, key=True, options=(), target=("challenge", "challenge name, e.g. self-assessment")):
|
|
9
|
+
parser = argparse.ArgumentParser(description=description)
|
|
10
|
+
if key:
|
|
11
|
+
parser.add_argument("--api-key", help="your API key (default: $RAID_KEY)")
|
|
12
|
+
for flag, text in options:
|
|
13
|
+
parser.add_argument(flag, action="store_true", help=text)
|
|
14
|
+
parser.add_argument(target[0], help=target[1])
|
|
15
|
+
args = parser.parse_args()
|
|
16
|
+
try:
|
|
17
|
+
action(Raid(getattr(args, "api_key", None)), args)
|
|
18
|
+
except RaidError as e:
|
|
19
|
+
sys.exit(f"Error: {e}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fetch():
|
|
23
|
+
_run("Download a challenge into ./<challenge>/.", lambda raid, a: raid.fetch(a.challenge, a.force),
|
|
24
|
+
options=[("--force", "download again and overwrite the challenge files")])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check():
|
|
28
|
+
_run("Score ./<challenge>/ locally on data/.", lambda raid, a: raid.check(a.challenge, a.hidden, a.reference),
|
|
29
|
+
key=False, options=[("--hidden", "challenge authors: score on hidden/ instead of data/"),
|
|
30
|
+
("--reference", "challenge authors: score reference/ instead of the student files")])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def submit():
|
|
34
|
+
def action(raid, args):
|
|
35
|
+
sid = raid.submit(args.challenge)
|
|
36
|
+
try:
|
|
37
|
+
raid.wait(sid)
|
|
38
|
+
except KeyboardInterrupt:
|
|
39
|
+
sys.exit("\nStopped waiting. Your submission is still being scored.")
|
|
40
|
+
_run("Submit ./<challenge>/ (without data/, scoring/ and .raidignore patterns), wait for the score and print the log.", action)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def push():
|
|
44
|
+
_run("Admins: upload a challenge directory (without reference/) as its new active version. "
|
|
45
|
+
"challenge.yaml supplies the name, the version and the metadata of a new challenge.",
|
|
46
|
+
lambda raid, a: raid.push(a.directory), target=("directory", "challenge directory, e.g. self-assessment"))
|