mlsec-raid 0.3.0__tar.gz → 0.4.2__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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: mlsec-raid
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Client for the RAID competition system
|
|
5
5
|
Requires-Python: >=3.9
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -15,11 +15,16 @@ export RAID_KEY=<your-api-key> # or pass --api-key <y
|
|
|
15
15
|
|
|
16
16
|
task-fetch <challenge> # downloads the challenge into ./<challenge>/
|
|
17
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
|
|
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
|
+
# and the submissions left today on challenges with a daily limit
|
|
21
|
+
task-log <job> # prints the log of a finished job, including the errors of your solution
|
|
19
22
|
```
|
|
20
23
|
|
|
24
|
+
Every command also works as `task <command>`, e.g. `task submit <challenge>`.
|
|
25
|
+
|
|
21
26
|
The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
|
|
22
27
|
|
|
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
|
|
28
|
+
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, version, wave, baseline, badge and `submissions_per_day` (keys it leaves out keep their current values), and the upload becomes the active version.
|
|
24
29
|
|
|
25
30
|
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`).
|
|
@@ -8,11 +8,16 @@ export RAID_KEY=<your-api-key> # or pass --api-key <y
|
|
|
8
8
|
|
|
9
9
|
task-fetch <challenge> # downloads the challenge into ./<challenge>/
|
|
10
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
|
|
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
|
+
# and the submissions left today on challenges with a daily limit
|
|
14
|
+
task-log <job> # prints the log of a finished job, including the errors of your solution
|
|
12
15
|
```
|
|
13
16
|
|
|
17
|
+
Every command also works as `task <command>`, e.g. `task submit <challenge>`.
|
|
18
|
+
|
|
14
19
|
The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
|
|
15
20
|
|
|
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
|
|
21
|
+
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, version, wave, baseline, badge and `submissions_per_day` (keys it leaves out keep their current values), and the upload becomes the active version.
|
|
17
22
|
|
|
18
23
|
Development: `uv sync` sets up the environment, `uv build` builds the package. Set `RAID_URL` to use another server. Release: bump `version` in `pyproject.toml`, then `uv build && uv publish` (PyPI token in `UV_PUBLISH_TOKEN`).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "mlsec-raid"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.2"
|
|
4
4
|
description = "Client for the RAID competition system"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.9"
|
|
@@ -10,7 +10,10 @@ dependencies = []
|
|
|
10
10
|
task-fetch = "raid.cli:fetch"
|
|
11
11
|
task-check = "raid.cli:check"
|
|
12
12
|
task-submit = "raid.cli:submit"
|
|
13
|
+
task-show = "raid.cli:show"
|
|
14
|
+
task-log = "raid.cli:log"
|
|
13
15
|
task-push = "raid.cli:push"
|
|
16
|
+
task = "raid.cli:main"
|
|
14
17
|
|
|
15
18
|
[build-system]
|
|
16
19
|
requires = ["uv_build>=0.8.17,<0.9.0"]
|
|
@@ -15,7 +15,7 @@ import urllib.request
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
17
|
__all__ = ["Raid", "RaidError"]
|
|
18
|
-
__version__ = "0.
|
|
18
|
+
__version__ = "0.4.2"
|
|
19
19
|
|
|
20
20
|
DEFAULT_URL = "https://raid.mlsec.tu-berlin.de"
|
|
21
21
|
PARTS = ("source", "data", "scoring") # source/ is unpacked into the challenge directory itself
|
|
@@ -54,8 +54,11 @@ class Raid:
|
|
|
54
54
|
with tempfile.TemporaryDirectory() as tmp:
|
|
55
55
|
for part in PARTS:
|
|
56
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
|
-
|
|
57
|
+
open(Path(tmp) / f"{part}.tar.gz", "wb") as f, \
|
|
58
|
+
_Progress(f"Downloading {part}", r.headers.get("Content-Length")) as bar:
|
|
59
|
+
while chunk := r.read(1 << 16):
|
|
60
|
+
f.write(chunk)
|
|
61
|
+
bar.update(len(chunk))
|
|
59
62
|
target.mkdir(parents=True, exist_ok=True)
|
|
60
63
|
for part in PARTS:
|
|
61
64
|
_extract(Path(tmp) / f"{part}.tar.gz", target, part)
|
|
@@ -104,30 +107,32 @@ class Raid:
|
|
|
104
107
|
raise RaidError(f"Missing required files in {target}/: " + ", ".join(missing))
|
|
105
108
|
with tempfile.TemporaryDirectory() as tmp:
|
|
106
109
|
archive = Path(tmp) / "submission.tar.gz"
|
|
110
|
+
_status(f"Packing {target}/ ...")
|
|
107
111
|
_pack(target, archive)
|
|
108
112
|
size = archive.stat().st_size
|
|
109
|
-
with open(archive, "rb") as f, self._request(
|
|
110
|
-
"POST", f"/api/submit/{challenge}", data=f, timeout=900,
|
|
113
|
+
with open(archive, "rb") as f, _Progress("Uploading", size) as bar, self._request(
|
|
114
|
+
"POST", f"/api/submit/{challenge}", data=bar.reader(f), timeout=900,
|
|
111
115
|
headers={"Content-Type": "application/gzip", "Content-Length": str(size)}) as r:
|
|
112
116
|
result = json.load(r)
|
|
113
|
-
print(
|
|
117
|
+
print("Job submitted!\nRun task-show to see the status of your jobs.")
|
|
114
118
|
return result["id"]
|
|
115
119
|
|
|
116
120
|
def push(self, directory):
|
|
117
121
|
"""Admins: upload a challenge directory (everything but reference/) as a new, active version.
|
|
118
122
|
|
|
119
|
-
Its challenge.yaml supplies the name, the version and
|
|
120
|
-
|
|
123
|
+
Its challenge.yaml supplies the name, the version and the metadata (title, wave, baseline, badge_score,
|
|
124
|
+
badge_points); keys it leaves out keep their current values. Returns the server's summary."""
|
|
121
125
|
target = Path(directory)
|
|
122
126
|
missing = [p for p in ("challenge.yaml", "data", "scoring", "hidden") if not (target / p).exists()]
|
|
123
127
|
if missing:
|
|
124
128
|
raise RaidError(f"Missing in {target}/: " + ", ".join(missing))
|
|
125
129
|
with tempfile.TemporaryDirectory() as tmp:
|
|
126
130
|
archive = Path(tmp) / "challenge.tar.gz"
|
|
131
|
+
_status(f"Packing {target}/ ...")
|
|
127
132
|
_pack(target, archive, own={"reference"})
|
|
128
133
|
size = archive.stat().st_size
|
|
129
|
-
with open(archive, "rb") as f, self._request(
|
|
130
|
-
"POST", "/api/admin/push", data=f, timeout=3600,
|
|
134
|
+
with open(archive, "rb") as f, _Progress("Uploading", size) as bar, self._request(
|
|
135
|
+
"POST", "/api/admin/push", data=bar.reader(f), timeout=3600,
|
|
131
136
|
headers={"Content-Type": "application/gzip", "Content-Length": str(size)}) as r:
|
|
132
137
|
result = json.load(r)
|
|
133
138
|
wave = f"wave {result['wave']}" if result["wave"] else "the self-assessment"
|
|
@@ -149,20 +154,34 @@ class Raid:
|
|
|
149
154
|
return sub
|
|
150
155
|
|
|
151
156
|
def status(self, challenge=None):
|
|
152
|
-
"""Print your submissions
|
|
157
|
+
"""Print your jobs (submissions, newest first) and return them as a list of dicts."""
|
|
153
158
|
query = f"?challenge={_check(challenge)}" if challenge else ""
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
result = self._json(f"/api/submissions{query}")
|
|
160
|
+
subs = result["submissions"]
|
|
161
|
+
print(f"{'JOB':>6} {'CHALLENGE':<20} {'VERSION':<8} {'STATE':<8} {'SCORE':>10} SUBMITTED")
|
|
156
162
|
for s in subs:
|
|
157
163
|
score = "" if s["score"] is None else f"{s['score']:.4f}"
|
|
158
164
|
print(f"{s['id']:>6} {s['challenge']:<20} {s['version']:<8} {s['status']:<8} {score:>10} {s['submitted_at'][:19]}")
|
|
159
165
|
if not subs:
|
|
160
|
-
print("No
|
|
166
|
+
print("No jobs yet.")
|
|
167
|
+
else:
|
|
168
|
+
print("Run task-log <job> to see the log of a finished job.")
|
|
169
|
+
limits = result.get("daily_limits") or []
|
|
170
|
+
if limits:
|
|
171
|
+
print("\nDaily submission limits (in competition mode per team, reset at 00:00 UTC):")
|
|
172
|
+
for d in limits:
|
|
173
|
+
left = max(d["submissions_per_day"] - d["submitted_today"], 0)
|
|
174
|
+
print(f" {d['challenge']:<20} {left} of {d['submissions_per_day']} left today")
|
|
161
175
|
return subs
|
|
162
176
|
|
|
163
177
|
def log(self, submission_id):
|
|
164
|
-
"""Return the scoring log of one of your submissions."""
|
|
165
|
-
|
|
178
|
+
"""Return the scoring log of one of your finished submissions: the output and errors of the scoring run."""
|
|
179
|
+
if not str(submission_id).isdigit():
|
|
180
|
+
raise RaidError(f"Invalid job id {submission_id!r}. task-show lists your jobs.")
|
|
181
|
+
sub = self._json(f"/api/submissions/{submission_id}")
|
|
182
|
+
if sub["status"] in ("pending", "running"):
|
|
183
|
+
raise RaidError(f"Job {sub['id']} is {sub['status']}. Its log is available once it has finished.")
|
|
184
|
+
return sub["log"] or "No log output."
|
|
166
185
|
|
|
167
186
|
def _request(self, method, endpoint, data=None, headers=None, timeout=60):
|
|
168
187
|
if not self.api_key:
|
|
@@ -185,6 +204,63 @@ class Raid:
|
|
|
185
204
|
return json.load(r)
|
|
186
205
|
|
|
187
206
|
|
|
207
|
+
def _status(text):
|
|
208
|
+
"""A transient line on stderr, only when it is a terminal."""
|
|
209
|
+
if sys.stderr.isatty():
|
|
210
|
+
sys.stderr.write(f"\r\033[K{text}")
|
|
211
|
+
sys.stderr.flush()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class _Progress:
|
|
215
|
+
"""A one-line progress bar on stderr, only when it is a terminal. Without a known total it counts bytes."""
|
|
216
|
+
|
|
217
|
+
def __init__(self, label, total=None):
|
|
218
|
+
self.label, self.total, self.done, self.shown = label, int(total) if total else None, 0, 0.0
|
|
219
|
+
self.tty = sys.stderr.isatty()
|
|
220
|
+
|
|
221
|
+
def __enter__(self):
|
|
222
|
+
self._draw(force=True)
|
|
223
|
+
return self
|
|
224
|
+
|
|
225
|
+
def __exit__(self, *exc):
|
|
226
|
+
if self.tty:
|
|
227
|
+
self._draw(force=True)
|
|
228
|
+
sys.stderr.write("\n" if exc[0] is None else "\r\033[K")
|
|
229
|
+
sys.stderr.flush()
|
|
230
|
+
|
|
231
|
+
def update(self, n):
|
|
232
|
+
self.done += n
|
|
233
|
+
self._draw()
|
|
234
|
+
|
|
235
|
+
def reader(self, f):
|
|
236
|
+
"""f with a read() that advances the bar, for uploads."""
|
|
237
|
+
bar = self
|
|
238
|
+
|
|
239
|
+
class Reader:
|
|
240
|
+
def read(self, n=-1):
|
|
241
|
+
chunk = f.read(n)
|
|
242
|
+
bar.update(len(chunk))
|
|
243
|
+
return chunk
|
|
244
|
+
return Reader()
|
|
245
|
+
|
|
246
|
+
def _draw(self, force=False):
|
|
247
|
+
now = time.monotonic()
|
|
248
|
+
if not self.tty or (not force and now - self.shown < 0.1):
|
|
249
|
+
return
|
|
250
|
+
self.shown = now
|
|
251
|
+
if self.total:
|
|
252
|
+
frac = min(self.done / self.total, 1)
|
|
253
|
+
line = f"{self.label} [{'#' * int(frac * 30):<30}] {frac:4.0%} {_size(self.done)} of {_size(self.total)}"
|
|
254
|
+
else:
|
|
255
|
+
line = f"{self.label} {_size(self.done)}"
|
|
256
|
+
sys.stderr.write(f"\r\033[K{line}")
|
|
257
|
+
sys.stderr.flush()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _size(n):
|
|
261
|
+
return f"{n / 1e6:.1f} MB" if n >= 1e6 else f"{n / 1e3:.0f} kB"
|
|
262
|
+
|
|
263
|
+
|
|
188
264
|
def _check(challenge):
|
|
189
265
|
if not isinstance(challenge, str) or not NAME_RE.match(challenge):
|
|
190
266
|
raise RaidError(f"Invalid challenge name {challenge!r}.")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Command line tools: task-fetch, task-check, task-submit and task-push."""
|
|
1
|
+
"""Command line tools: task-fetch, task-check, task-submit, task-show, task-log and task-push (also as `task <command>`)."""
|
|
2
2
|
import argparse
|
|
3
3
|
import sys
|
|
4
4
|
|
|
@@ -11,7 +11,8 @@ def _run(description, action, key=True, options=(), target=("challenge", "challe
|
|
|
11
11
|
parser.add_argument("--api-key", help="your API key (default: $RAID_KEY)")
|
|
12
12
|
for flag, text in options:
|
|
13
13
|
parser.add_argument(flag, action="store_true", help=text)
|
|
14
|
-
|
|
14
|
+
if target:
|
|
15
|
+
parser.add_argument(target[0], help=target[1])
|
|
15
16
|
args = parser.parse_args()
|
|
16
17
|
try:
|
|
17
18
|
action(Raid(getattr(args, "api_key", None)), args)
|
|
@@ -31,16 +32,30 @@ def check():
|
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
def submit():
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
_run("Submit ./<challenge>/ for scoring (without data/, scoring/ and .raidignore patterns).",
|
|
36
|
+
lambda raid, a: raid.submit(a.challenge))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def show():
|
|
40
|
+
_run("List your jobs (submissions) with their state and score.", lambda raid, a: raid.status(), target=None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def log():
|
|
44
|
+
_run("Print the log of a finished job: the output and errors of its scoring run.",
|
|
45
|
+
lambda raid, a: print(raid.log(a.job)), target=("job", "job id, see task-show"))
|
|
41
46
|
|
|
42
47
|
|
|
43
48
|
def push():
|
|
44
49
|
_run("Admins: upload a challenge directory (without reference/) as its new active version. "
|
|
45
|
-
"challenge.yaml supplies the name, the version and the metadata
|
|
50
|
+
"challenge.yaml supplies the name, the version and the metadata.",
|
|
46
51
|
lambda raid, a: raid.push(a.directory), target=("directory", "challenge directory, e.g. self-assessment"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main():
|
|
55
|
+
"""`task submit <challenge>` is the same as `task-submit <challenge>`."""
|
|
56
|
+
commands = {"fetch": fetch, "check": check, "submit": submit, "show": show, "log": log, "push": push}
|
|
57
|
+
if len(sys.argv) < 2 or sys.argv[1] not in commands:
|
|
58
|
+
sys.exit("Usage: task {" + ",".join(commands) + "} ...")
|
|
59
|
+
command = sys.argv.pop(1)
|
|
60
|
+
sys.argv[0] = f"task {command}"
|
|
61
|
+
commands[command]()
|