mlsec-raid 0.4.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.4.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
@@ -16,7 +16,8 @@ export RAID_KEY=<your-api-key> # or pass --api-key <y
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
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
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
20
21
  task-log <job> # prints the log of a finished job, including the errors of your solution
21
22
  ```
22
23
 
@@ -24,6 +25,6 @@ Every command also works as `task <command>`, e.g. `task submit <challenge>`.
24
25
 
25
26
  The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
26
27
 
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
+ 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.
28
29
 
29
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`).
@@ -9,7 +9,8 @@ export RAID_KEY=<your-api-key> # or pass --api-key <y
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
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
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
13
14
  task-log <job> # prints the log of a finished job, including the errors of your solution
14
15
  ```
15
16
 
@@ -17,6 +18,6 @@ Every command also works as `task <command>`, e.g. `task submit <challenge>`.
17
18
 
18
19
  The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
19
20
 
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
+ 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.
21
22
 
22
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.4.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"
@@ -15,7 +15,7 @@ import urllib.request
15
15
  from pathlib import Path
16
16
 
17
17
  __all__ = ["Raid", "RaidError"]
18
- __version__ = "0.4.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,10 +107,11 @@ 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
117
  print("Job submitted!\nRun task-show to see the status of your jobs.")
@@ -116,18 +120,19 @@ class Raid:
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"
@@ -151,7 +156,8 @@ class Raid:
151
156
  def status(self, challenge=None):
152
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"]
159
+ result = self._json(f"/api/submissions{query}")
160
+ subs = result["submissions"]
155
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}"
@@ -160,6 +166,12 @@ class Raid:
160
166
  print("No jobs yet.")
161
167
  else:
162
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")
163
175
  return subs
164
176
 
165
177
  def log(self, submission_id):
@@ -192,6 +204,63 @@ class Raid:
192
204
  return json.load(r)
193
205
 
194
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
+
195
264
  def _check(challenge):
196
265
  if not isinstance(challenge, str) or not NAME_RE.match(challenge):
197
266
  raise RaidError(f"Invalid challenge name {challenge!r}.")
@@ -47,7 +47,7 @@ def log():
47
47
 
48
48
  def push():
49
49
  _run("Admins: upload a challenge directory (without reference/) as its new active version. "
50
- "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.",
51
51
  lambda raid, a: raid.push(a.directory), target=("directory", "challenge directory, e.g. self-assessment"))
52
52
 
53
53