mlsec-raid 0.3.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,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,18 @@
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, prints score and log
12
+ ```
13
+
14
+ The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
15
+
16
+ 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.
17
+
18
+ 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,20 @@
1
+ [project]
2
+ name = "mlsec-raid"
3
+ version = "0.3.0"
4
+ description = "Client for the RAID competition system"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ dependencies = []
8
+
9
+ [project.scripts]
10
+ task-fetch = "raid.cli:fetch"
11
+ task-check = "raid.cli:check"
12
+ task-submit = "raid.cli:submit"
13
+ task-push = "raid.cli:push"
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.8.17,<0.9.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [tool.uv.build-backend]
20
+ module-name = "raid"
@@ -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)
@@ -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"))