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.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, prints score and log
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 and version (and the wave, baseline and badge of a new challenge), and the upload becomes the active version.
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, prints score and log
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 and version (and the wave, baseline and badge of a new challenge), and the upload becomes the active version.
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.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.3.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
- shutil.copyfileobj(r, f, 1 << 20)
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(f"Submitted {challenge} ({size / 1e6:.2f} MB) as submission {result['id']} (challenge version {result['version']}).")
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, 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."""
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 (newest first) and return them as a list of dicts."""
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
- subs = self._json(f"/api/submissions{query}")["submissions"]
155
- print(f"{'ID':>6} {'CHALLENGE':<20} {'VERSION':<8} {'STATUS':<8} {'SCORE':>10} SUBMITTED")
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 submissions yet.")
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
- return self._json(f"/api/submissions/{int(submission_id)}")["log"]
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
- parser.add_argument(target[0], help=target[1])
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
- 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)
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 of a new challenge.",
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]()