mlsec-raid 0.4.2__tar.gz → 0.5.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.
- mlsec_raid-0.5.0/PKG-INFO +45 -0
- mlsec_raid-0.5.0/README.md +38 -0
- {mlsec_raid-0.4.2 → mlsec_raid-0.5.0}/pyproject.toml +1 -1
- {mlsec_raid-0.4.2 → mlsec_raid-0.5.0}/src/raid/__init__.py +161 -29
- mlsec_raid-0.5.0/src/raid/_container.py +177 -0
- mlsec_raid-0.5.0/src/raid/_runner.py +46 -0
- {mlsec_raid-0.4.2 → mlsec_raid-0.5.0}/src/raid/cli.py +26 -6
- mlsec_raid-0.4.2/PKG-INFO +0 -30
- mlsec_raid-0.4.2/README.md +0 -23
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mlsec-raid
|
|
3
|
+
Version: 0.5.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 its data, in a container
|
|
18
|
+
task-check method <challenge> # only the method: runs method/run like our backend's sanity check
|
|
19
|
+
task-check artifact <challenge> # only the artifact: scores artifact/result/
|
|
20
|
+
task-submit <challenge> # submits ./<challenge>/ without its data, scoring/ and .raidignore patterns
|
|
21
|
+
task-show # lists your jobs with their state (pending, running, success, failed) and
|
|
22
|
+
# score, and the submissions left today on challenges with a daily limit
|
|
23
|
+
task-log <job> # prints the log of a finished job, including the errors of your solution
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Every command also works as `task <command>`, e.g. `task submit <challenge>` or `task check method <challenge>`.
|
|
27
|
+
|
|
28
|
+
Challenges like dawn separate the method from the artifact:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
<challenge>/
|
|
32
|
+
artifact/data/ the data, never submitted
|
|
33
|
+
artifact/result/ the artifact you submit, e.g. dawn's re-implementations
|
|
34
|
+
method/run your method, which the backend runs once as a sanity check (dawn: on the simple target)
|
|
35
|
+
scoring/score.py method(), artifact() and score(), the backend's scoring
|
|
36
|
+
setup.sh your installs
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`task-check` runs `scoring/score.py` in the container `raid-<challenge>`, built like the backend's sandbox: with Docker an image on Debian trixie (slim) with python3 and the challenge's Debian packages (`scoring/packages.txt`), in which the challenge's `setup.sh` runs as root during the build; with only udocker (`pip install udocker`) a udocker container of `debian:trixie` set up the same way. It is built again only when `setup.sh` or the packages change (their hash is the image's `raid.setup` label, or `/etc/raid-setup.sha256` in the udocker container), or with `--rebuild`. Like on the backend, `score.py` sees only Debian's Python packages; unlike there, the method may use the internet. `task-check method` calls `score.py`'s `method()`, `task-check artifact` its `artifact()` and `task-check` its `score()`, which checks the method first and then the artifact. Challenges without that split (e.g. self-assessment) have only `task-check <challenge>`. `--no-container` checks on this machine instead.
|
|
40
|
+
|
|
41
|
+
The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
|
|
42
|
+
|
|
43
|
+
Challenge authors: `task-check <challenge> --hidden --reference` scores `reference/` on `hidden/` (the packages come from `challenge.yaml`). 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.
|
|
44
|
+
|
|
45
|
+
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,38 @@
|
|
|
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 its data, in a container
|
|
11
|
+
task-check method <challenge> # only the method: runs method/run like our backend's sanity check
|
|
12
|
+
task-check artifact <challenge> # only the artifact: scores artifact/result/
|
|
13
|
+
task-submit <challenge> # submits ./<challenge>/ without its data, scoring/ and .raidignore patterns
|
|
14
|
+
task-show # lists your jobs with their state (pending, running, success, failed) and
|
|
15
|
+
# score, and the submissions left today on challenges with a daily limit
|
|
16
|
+
task-log <job> # prints the log of a finished job, including the errors of your solution
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Every command also works as `task <command>`, e.g. `task submit <challenge>` or `task check method <challenge>`.
|
|
20
|
+
|
|
21
|
+
Challenges like dawn separate the method from the artifact:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
<challenge>/
|
|
25
|
+
artifact/data/ the data, never submitted
|
|
26
|
+
artifact/result/ the artifact you submit, e.g. dawn's re-implementations
|
|
27
|
+
method/run your method, which the backend runs once as a sanity check (dawn: on the simple target)
|
|
28
|
+
scoring/score.py method(), artifact() and score(), the backend's scoring
|
|
29
|
+
setup.sh your installs
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`task-check` runs `scoring/score.py` in the container `raid-<challenge>`, built like the backend's sandbox: with Docker an image on Debian trixie (slim) with python3 and the challenge's Debian packages (`scoring/packages.txt`), in which the challenge's `setup.sh` runs as root during the build; with only udocker (`pip install udocker`) a udocker container of `debian:trixie` set up the same way. It is built again only when `setup.sh` or the packages change (their hash is the image's `raid.setup` label, or `/etc/raid-setup.sha256` in the udocker container), or with `--rebuild`. Like on the backend, `score.py` sees only Debian's Python packages; unlike there, the method may use the internet. `task-check method` calls `score.py`'s `method()`, `task-check artifact` its `artifact()` and `task-check` its `score()`, which checks the method first and then the artifact. Challenges without that split (e.g. self-assessment) have only `task-check <challenge>`. `--no-container` checks on this machine instead.
|
|
33
|
+
|
|
34
|
+
The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
|
|
35
|
+
|
|
36
|
+
Challenge authors: `task-check <challenge> --hidden --reference` scores `reference/` on `hidden/` (the packages come from `challenge.yaml`). 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.
|
|
37
|
+
|
|
38
|
+
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,4 +1,5 @@
|
|
|
1
1
|
"""Client for the RAID competition system: fetch challenges, check and submit solutions."""
|
|
2
|
+
import ast
|
|
2
3
|
import fnmatch
|
|
3
4
|
import importlib.util
|
|
4
5
|
import json
|
|
@@ -15,11 +16,14 @@ import urllib.request
|
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
|
|
17
18
|
__all__ = ["Raid", "RaidError"]
|
|
18
|
-
__version__ = "0.
|
|
19
|
+
__version__ = "0.5.0"
|
|
19
20
|
|
|
20
21
|
DEFAULT_URL = "https://raid.mlsec.tu-berlin.de"
|
|
21
22
|
PARTS = ("source", "data", "scoring") # source/ is unpacked into the challenge directory itself
|
|
22
|
-
|
|
23
|
+
# Parts of the challenge directory that are never submitted: these top-level entries and artifact/data/
|
|
24
|
+
OWN = {"data", "scoring", "hidden", "reference", "challenge.yaml", "entrypoint.sh"}
|
|
25
|
+
ARTIFACT_DATA = Path("artifact", "data") # the data/ part of challenges split into artifact/ and method/
|
|
26
|
+
CHECKS = {"method": "Method score", "artifact": "Artifact score", None: "Score"} # the part checked: what it prints
|
|
23
27
|
SKIP = {"__pycache__", ".git", ".ipynb_checkpoints", ".venv", "venv", ".DS_Store"}
|
|
24
28
|
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
|
25
29
|
|
|
@@ -41,7 +45,7 @@ class Raid:
|
|
|
41
45
|
return self._json("/api/howto")["text"]
|
|
42
46
|
|
|
43
47
|
def fetch(self, challenge, force=False):
|
|
44
|
-
"""Download a challenge into ./<challenge>/: its files, data/ and scoring/.
|
|
48
|
+
"""Download a challenge into ./<challenge>/: its files, data/ (artifact/data/) and scoring/.
|
|
45
49
|
|
|
46
50
|
An existing directory is left untouched unless force=True, which overwrites the challenge files.
|
|
47
51
|
"""
|
|
@@ -65,35 +69,62 @@ class Raid:
|
|
|
65
69
|
print(f"Fetched {challenge} (version {info['version']}) into {target}/. Start with {target / 'README.txt'}.")
|
|
66
70
|
return target
|
|
67
71
|
|
|
68
|
-
def check(self, challenge, hidden=False, reference=False):
|
|
69
|
-
"""
|
|
72
|
+
def check(self, challenge, part=None, hidden=False, reference=False, container=True, rebuild=False):
|
|
73
|
+
"""Check ./<challenge>/ locally with scoring/score.py on its data (artifact/data/ or data/). Returns the score.
|
|
74
|
+
|
|
75
|
+
part="method" calls score.py's method(), part="artifact" its artifact() and part=None its score(), which
|
|
76
|
+
checks the method first and then the artifact. It runs in the container raid-<challenge>, built like the
|
|
77
|
+
backend's sandbox with the challenge's setup.sh (Docker, else udocker) and rebuilt when setup.sh changes or
|
|
78
|
+
rebuild=True; container=False runs it on this machine instead.
|
|
70
79
|
|
|
71
80
|
For challenge authors: hidden=True scores on hidden/, reference=True scores the files
|
|
72
|
-
in reference/ in place of the student files.
|
|
81
|
+
in reference/ in place of the student files. Raises RaidError when scoring stops without a score.
|
|
73
82
|
"""
|
|
83
|
+
if part not in CHECKS:
|
|
84
|
+
raise RaidError(f"Invalid check {part!r}, use method or artifact.")
|
|
74
85
|
target = (self.path / _check(challenge)).resolve()
|
|
75
86
|
script = target / "scoring" / "score.py"
|
|
76
|
-
dataset = target /
|
|
87
|
+
dataset = target / "hidden" if hidden else _data(target)
|
|
77
88
|
solution = target / "reference" if reference else target
|
|
78
89
|
for path in (script, dataset, solution):
|
|
79
90
|
if not path.exists():
|
|
80
91
|
raise RaidError(f"{path} not found." + (" Fetch the challenge first." if path == script else ""))
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
function = part or "score"
|
|
93
|
+
defined = _functions(script)
|
|
94
|
+
if part and defined is not None and part not in defined:
|
|
95
|
+
raise RaidError(f"{challenge} has no separate {part} check (scoring/score.py has no {part}()). "
|
|
96
|
+
f"Run task check {challenge}.")
|
|
97
|
+
files = "reference/" if reference else "your files"
|
|
98
|
+
what = f"{challenge} {part}" if part else challenge
|
|
99
|
+
here = str(Path.cwd().resolve()) + os.sep # shown paths are relative, e.g. self-assessment/solution.c
|
|
100
|
+
shown = f"{dataset.relative_to(target).as_posix()}/"
|
|
101
|
+
if container:
|
|
102
|
+
from . import _container
|
|
103
|
+
kind = _container.engine()
|
|
104
|
+
if kind is None:
|
|
105
|
+
raise RaidError("task check needs Docker or udocker (pip install udocker) to build the container "
|
|
106
|
+
f"raid-{challenge}. Pass --no-container to check on this machine instead.")
|
|
107
|
+
box = _container.Container(challenge, target, kind)
|
|
108
|
+
print(f"Checking {what}: {files} on {shown} in {box.name} ({kind})")
|
|
109
|
+
try:
|
|
110
|
+
box.ensure(rebuild, status=lambda text: print(_indent(text, here)))
|
|
111
|
+
except _container.ContainerError as e:
|
|
112
|
+
raise RaidError(str(e)) from None
|
|
113
|
+
inside = (str(target) + os.sep).replace(here, "") # /job/ in the container is the challenge directory
|
|
114
|
+
with _Indented(here, {"/job/": inside}) as out:
|
|
115
|
+
result = box.call(function, dataset, solution, out.write)
|
|
116
|
+
result["traceback"] = result.get("traceback", "").replace("/job/", inside)
|
|
117
|
+
else:
|
|
118
|
+
print(f"Checking {what}: {files} on {shown}")
|
|
119
|
+
result = _call_here(function, script, dataset, solution, target, here)
|
|
120
|
+
if "exit" in result:
|
|
121
|
+
raise RaidError("No score, scoring stopped:\n" + _indent(str(result["exit"]).strip(), here))
|
|
122
|
+
if "error" in result:
|
|
123
|
+
raise RaidError(f"No score, scoring/score.py raised {result['error']}:\n" + _indent(result["traceback"], here)
|
|
124
|
+
+ ("\nInstall the packages the challenge needs, see its setup.sh."
|
|
125
|
+
if result["error"] == "ModuleNotFoundError" and not container else ""))
|
|
126
|
+
value = result["value"]
|
|
127
|
+
print(f"\n{_bold(f'{CHECKS[part]}: {value:.4f}')}")
|
|
97
128
|
return value
|
|
98
129
|
|
|
99
130
|
def submit(self, challenge):
|
|
@@ -123,7 +154,9 @@ class Raid:
|
|
|
123
154
|
Its challenge.yaml supplies the name, the version and the metadata (title, wave, baseline, badge_score,
|
|
124
155
|
badge_points); keys it leaves out keep their current values. Returns the server's summary."""
|
|
125
156
|
target = Path(directory)
|
|
126
|
-
missing = [p for p in ("challenge.yaml", "
|
|
157
|
+
missing = [p for p in ("challenge.yaml", "scoring", "hidden") if not (target / p).exists()]
|
|
158
|
+
if not (target / "data").is_dir() and not (target / ARTIFACT_DATA).is_dir():
|
|
159
|
+
missing.append(f"{ARTIFACT_DATA.as_posix()} (or data)")
|
|
127
160
|
if missing:
|
|
128
161
|
raise RaidError(f"Missing in {target}/: " + ", ".join(missing))
|
|
129
162
|
with tempfile.TemporaryDirectory() as tmp:
|
|
@@ -211,6 +244,60 @@ def _status(text):
|
|
|
211
244
|
sys.stderr.flush()
|
|
212
245
|
|
|
213
246
|
|
|
247
|
+
class _Indented:
|
|
248
|
+
"""Indent everything printed to stdout and stderr inside the with block by two spaces; paths under here become
|
|
249
|
+
relative, and the keys of paths (e.g. /job/ in the container) become their values."""
|
|
250
|
+
|
|
251
|
+
class _Stream:
|
|
252
|
+
def __init__(self, stream, paths):
|
|
253
|
+
self.stream, self.paths, self.line_start = stream, paths, True
|
|
254
|
+
|
|
255
|
+
def write(self, text):
|
|
256
|
+
for line in text.splitlines(keepends=True):
|
|
257
|
+
if self.line_start and line.strip():
|
|
258
|
+
self.stream.write(" ")
|
|
259
|
+
for old, new in self.paths.items():
|
|
260
|
+
line = line.replace(old, new)
|
|
261
|
+
self.stream.write(line)
|
|
262
|
+
self.line_start = line.endswith(("\n", "\r"))
|
|
263
|
+
self.stream.flush()
|
|
264
|
+
return len(text)
|
|
265
|
+
|
|
266
|
+
def __getattr__(self, name):
|
|
267
|
+
return getattr(self.stream, name)
|
|
268
|
+
|
|
269
|
+
def __init__(self, here=None, paths=None):
|
|
270
|
+
self.paths = {here: ""} if here else {}
|
|
271
|
+
self.paths.update(paths or {})
|
|
272
|
+
|
|
273
|
+
def __enter__(self):
|
|
274
|
+
self.saved = sys.stdout, sys.stderr
|
|
275
|
+
sys.stdout, sys.stderr = self._Stream(sys.stdout, self.paths), self._Stream(sys.stderr, self.paths)
|
|
276
|
+
return sys.stdout
|
|
277
|
+
|
|
278
|
+
def __exit__(self, *exc):
|
|
279
|
+
for stream in (sys.stdout, sys.stderr):
|
|
280
|
+
stream.flush()
|
|
281
|
+
sys.stdout, sys.stderr = self.saved
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _indent(text, here):
|
|
285
|
+
return "\n".join(" " + line.replace(here, "") if line.strip() else line for line in text.splitlines())
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _traceback(error, target):
|
|
289
|
+
"""The traceback of an error in score.py, only with the frames of the challenge directory (score.py, solution)."""
|
|
290
|
+
import traceback
|
|
291
|
+
frames = [f for f in traceback.extract_tb(error.__traceback__)
|
|
292
|
+
if target in Path(f.filename).resolve().parents]
|
|
293
|
+
lines = traceback.format_list(frames) + traceback.format_exception_only(error)
|
|
294
|
+
return "".join(lines).rstrip()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _bold(text):
|
|
298
|
+
return f"\033[1m{text}\033[0m" if sys.stdout.isatty() and not os.environ.get("NO_COLOR") else text
|
|
299
|
+
|
|
300
|
+
|
|
214
301
|
class _Progress:
|
|
215
302
|
"""A one-line progress bar on stderr, only when it is a terminal. Without a known total it counts bytes."""
|
|
216
303
|
|
|
@@ -261,6 +348,47 @@ def _size(n):
|
|
|
261
348
|
return f"{n / 1e6:.1f} MB" if n >= 1e6 else f"{n / 1e3:.0f} kB"
|
|
262
349
|
|
|
263
350
|
|
|
351
|
+
def _data(target):
|
|
352
|
+
"""The challenge's data: artifact/data/ in challenges split into artifact/ and method/, else data/."""
|
|
353
|
+
return target / ARTIFACT_DATA if (target / ARTIFACT_DATA).is_dir() else target / "data"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _functions(script):
|
|
357
|
+
"""The names of the top-level functions of score.py, None if it cannot be parsed (running it shows why)."""
|
|
358
|
+
try:
|
|
359
|
+
tree = ast.parse(script.read_bytes())
|
|
360
|
+
except (SyntaxError, ValueError):
|
|
361
|
+
return None
|
|
362
|
+
return {n.name for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _call_here(function, script, dataset, solution, target, here):
|
|
366
|
+
"""Call function of score.py on dataset in this process, as the container's runner does. Returns its result dict."""
|
|
367
|
+
paths = [str(script.parent), str(solution)] # score.py imports the solution, e.g. `from solution import md5`
|
|
368
|
+
sys.path[:0] = paths
|
|
369
|
+
os.environ["RAID_SOLUTION"] = str(solution) # where score.py finds files that are not imported, e.g. solution.c
|
|
370
|
+
try:
|
|
371
|
+
with _Indented(here): # score.py's own output, set off from the result
|
|
372
|
+
spec = importlib.util.spec_from_file_location("score", script)
|
|
373
|
+
module = importlib.util.module_from_spec(spec)
|
|
374
|
+
spec.loader.exec_module(module)
|
|
375
|
+
value = getattr(module, function)(dataset)
|
|
376
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
377
|
+
return {"error": "TypeError", "traceback": f"{function}() must return a number, not {value!r}"}
|
|
378
|
+
return {"value": float(value)}
|
|
379
|
+
except SystemExit as e: # score.py stops with sys.exit("why"), e.g. when the solution does not compile
|
|
380
|
+
return {"exit": e.code if isinstance(e.code, str) else f"exit code {e.code}"}
|
|
381
|
+
except Exception as e:
|
|
382
|
+
return {"error": type(e).__name__, "traceback": _traceback(e, target)}
|
|
383
|
+
finally:
|
|
384
|
+
del os.environ["RAID_SOLUTION"]
|
|
385
|
+
for p in paths:
|
|
386
|
+
sys.path.remove(p)
|
|
387
|
+
for name, mod in list(sys.modules.items()): # a later check sees the current solution
|
|
388
|
+
if target in Path(getattr(mod, "__file__", None) or "/").resolve().parents:
|
|
389
|
+
del sys.modules[name]
|
|
390
|
+
|
|
391
|
+
|
|
264
392
|
def _check(challenge):
|
|
265
393
|
if not isinstance(challenge, str) or not NAME_RE.match(challenge):
|
|
266
394
|
raise RaidError(f"Invalid challenge name {challenge!r}.")
|
|
@@ -268,12 +396,14 @@ def _check(challenge):
|
|
|
268
396
|
|
|
269
397
|
|
|
270
398
|
def _extract(archive, target, part):
|
|
271
|
-
"""Extract only regular files and directories below part/, never outside target. source/ maps to target
|
|
399
|
+
"""Extract only regular files and directories below part/, never outside target. source/ maps to target, the
|
|
400
|
+
data archive of a challenge split into artifact/ and method/ holds artifact/data/."""
|
|
272
401
|
root = target.resolve()
|
|
402
|
+
allowed = [part, ARTIFACT_DATA.as_posix()] if part == "data" else [part]
|
|
273
403
|
with tarfile.open(archive, "r:gz") as tar:
|
|
274
404
|
for member in tar:
|
|
275
405
|
name = posixpath.normpath(member.name)
|
|
276
|
-
if name
|
|
406
|
+
if not any(name == a or name.startswith(a + "/") for a in allowed) and not (part == "data" and name == "artifact"):
|
|
277
407
|
raise RaidError(f"Unexpected entry {member.name!r} in the {part} archive.")
|
|
278
408
|
if part == "source":
|
|
279
409
|
name = name[len("source/"):] or "."
|
|
@@ -307,14 +437,16 @@ def _ignored(target):
|
|
|
307
437
|
|
|
308
438
|
def _pack(target, archive, own=OWN):
|
|
309
439
|
"""tar.gz of the challenge directory without the top-level entries in own (by default data/, scoring/,
|
|
310
|
-
hidden/, reference
|
|
311
|
-
VCS data and symlinks are left out."""
|
|
440
|
+
hidden/, reference/, challenge.yaml and entrypoint.sh, plus artifact/data/) and the .raidignore patterns.
|
|
441
|
+
Regular files only; caches, VCS data and symlinks are left out."""
|
|
442
|
+
nested = {ARTIFACT_DATA} if own is OWN else set()
|
|
312
443
|
ignored = _ignored(target)
|
|
313
444
|
with tarfile.open(archive, "w:gz") as tar:
|
|
314
445
|
for dirpath, dirnames, filenames in os.walk(target):
|
|
315
446
|
rel = Path(dirpath).relative_to(target)
|
|
316
447
|
top = rel == Path(".")
|
|
317
|
-
dirnames[:] = sorted(d for d in dirnames if d not in SKIP and not (top and d in own) and
|
|
448
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP and not (top and d in own) and rel / d not in nested
|
|
449
|
+
and not ignored(rel / d))
|
|
318
450
|
files = sorted(f for f in filenames if f not in SKIP and not (top and f in own) and not ignored(rel / f))
|
|
319
451
|
for name in dirnames + files:
|
|
320
452
|
path = Path(dirpath) / name
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""The local container raid-<challenge> in which task-check runs scoring/score.py, built like the backend's sandbox.
|
|
2
|
+
|
|
3
|
+
With Docker it is an image on the backend's base (Debian trixie slim with python3), the challenge's Debian packages
|
|
4
|
+
and the challenge's setup.sh, run as root during the build. With only udocker it is a udocker container of
|
|
5
|
+
debian:trixie in which the same is installed. Either is built again only when setup.sh or the packages change: the
|
|
6
|
+
image's label raid.setup (Docker) or the container's /etc/raid-setup.sha256 (udocker) holds their hash.
|
|
7
|
+
"""
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
DOCKER_BASE = "debian:trixie-slim" # the base of the backend's sandbox (raid-scoring-service runner/)
|
|
19
|
+
UDOCKER_BASE = "debian:trixie"
|
|
20
|
+
RECIPE = "1" # part of the hash: a new recipe rebuilds every container
|
|
21
|
+
# Trusted directories first, as on the backend: setup.sh's commands in /usr/local come last.
|
|
22
|
+
PATH = "/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/sbin:/usr/local/bin"
|
|
23
|
+
PASSED_ENV = re.compile(r"^(RAID_LLM_\w+|https?_proxy|no_proxy|HTTPS?_PROXY|NO_PROXY)$") # handed into the container
|
|
24
|
+
RUNNER = Path(__file__).with_name("_runner.py")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ContainerError(Exception):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def packages(target):
|
|
32
|
+
"""The challenge's Debian packages: scoring/packages.txt (from the server) or, for challenge authors,
|
|
33
|
+
requirements in challenge.yaml."""
|
|
34
|
+
listed = target / "scoring" / "packages.txt"
|
|
35
|
+
if listed.is_file():
|
|
36
|
+
return listed.read_text().split()
|
|
37
|
+
meta = target / "challenge.yaml"
|
|
38
|
+
return _requirements(meta.read_text()) if meta.is_file() else []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _requirements(text):
|
|
42
|
+
"""The requirements list of challenge.yaml, as a block (- gcc) or a flow sequence ([gcc, make])."""
|
|
43
|
+
lines = [line.split("#", 1)[0].rstrip() for line in text.splitlines()]
|
|
44
|
+
for i, line in enumerate(lines):
|
|
45
|
+
m = re.match(r"^requirements:\s*(.*)$", line)
|
|
46
|
+
if not m:
|
|
47
|
+
continue
|
|
48
|
+
if m.group(1).startswith("["):
|
|
49
|
+
flow = " ".join([m.group(1)] + lines[i + 1:])
|
|
50
|
+
return [p.strip().strip("'\"") for p in flow[1:flow.index("]")].split(",") if p.strip()]
|
|
51
|
+
found = []
|
|
52
|
+
for item in lines[i + 1:]:
|
|
53
|
+
if item and not item[0].isspace():
|
|
54
|
+
break
|
|
55
|
+
if item.strip().startswith("-"):
|
|
56
|
+
found.append(item.strip()[1:].strip().strip("'\""))
|
|
57
|
+
return found
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def engine():
|
|
62
|
+
"""docker when it is installed and its daemon answers, else udocker when it is installed, else None."""
|
|
63
|
+
if shutil.which("docker"):
|
|
64
|
+
try:
|
|
65
|
+
if subprocess.run(["docker", "info"], capture_output=True, timeout=30).returncode == 0:
|
|
66
|
+
return "docker"
|
|
67
|
+
except subprocess.TimeoutExpired:
|
|
68
|
+
pass
|
|
69
|
+
return "udocker" if shutil.which("udocker") else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Container:
|
|
73
|
+
def __init__(self, challenge, target, kind):
|
|
74
|
+
self.name, self.target, self.kind = f"raid-{challenge}", target, kind
|
|
75
|
+
self.packages = packages(target)
|
|
76
|
+
setup = target / "setup.sh"
|
|
77
|
+
self.setup = setup.read_bytes() if setup.is_file() else b""
|
|
78
|
+
base = DOCKER_BASE if kind == "docker" else UDOCKER_BASE
|
|
79
|
+
self.hash = hashlib.sha256(f"{RECIPE}\0{base}\0{' '.join(self.packages)}\0".encode() + self.setup).hexdigest()
|
|
80
|
+
|
|
81
|
+
# ---------- building ----------
|
|
82
|
+
|
|
83
|
+
def ensure(self, rebuild=False, status=print):
|
|
84
|
+
"""Build the container unless it exists with the current setup.sh and packages."""
|
|
85
|
+
if not rebuild and self._built_hash() == self.hash:
|
|
86
|
+
return
|
|
87
|
+
status(f"Building {self.name} with {'setup.sh' if self.setup.strip() else 'no setup.sh'}"
|
|
88
|
+
f"{' and ' + ' '.join(self.packages) if self.packages else ''} (once, and again when they change) ...")
|
|
89
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
90
|
+
Path(tmp, "setup.sh").write_bytes(self.setup)
|
|
91
|
+
if self.kind == "docker":
|
|
92
|
+
self._build_docker(Path(tmp))
|
|
93
|
+
else:
|
|
94
|
+
self._build_udocker(Path(tmp))
|
|
95
|
+
|
|
96
|
+
def _install(self):
|
|
97
|
+
return ("apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "
|
|
98
|
+
f"python3 ca-certificates {' '.join(self.packages)}")
|
|
99
|
+
|
|
100
|
+
def _built_hash(self):
|
|
101
|
+
if self.kind == "docker":
|
|
102
|
+
found = subprocess.run(["docker", "image", "inspect", "-f", '{{index .Config.Labels "raid.setup"}}',
|
|
103
|
+
self.name], capture_output=True, text=True)
|
|
104
|
+
return found.stdout.strip() if found.returncode == 0 else None
|
|
105
|
+
found = subprocess.run(self._udocker("inspect", "-p", self.name), capture_output=True, text=True)
|
|
106
|
+
if found.returncode:
|
|
107
|
+
return None
|
|
108
|
+
stamp = Path(found.stdout.strip().splitlines()[-1]) / "etc" / "raid-setup.sha256"
|
|
109
|
+
return stamp.read_text().strip() if stamp.is_file() else ""
|
|
110
|
+
|
|
111
|
+
def _build_docker(self, context):
|
|
112
|
+
dockerfile = (f"FROM {DOCKER_BASE} AS trusted\n"
|
|
113
|
+
f"RUN {self._install()} && rm -rf /var/lib/apt/lists/*\n")
|
|
114
|
+
if self.setup.strip(): # as on the backend: afterwards the trusted image is copied back over setup.sh's changes
|
|
115
|
+
dockerfile += ("FROM trusted\n"
|
|
116
|
+
"COPY setup.sh /tmp/raid-setup/setup.sh\n"
|
|
117
|
+
"RUN cd /tmp/raid-setup && DEBIAN_FRONTEND=noninteractive bash setup.sh\n"
|
|
118
|
+
"COPY --from=trusted / /\n"
|
|
119
|
+
"RUN rm -rf /tmp/raid-setup\n")
|
|
120
|
+
(context / "Dockerfile").write_text(dockerfile)
|
|
121
|
+
self._step(["docker", "build", "-t", self.name, "--label", f"raid.setup={self.hash}", str(context)], setup=True)
|
|
122
|
+
|
|
123
|
+
def _build_udocker(self, context):
|
|
124
|
+
if self._built_hash() is not None:
|
|
125
|
+
self._step(self._udocker("rm", self.name))
|
|
126
|
+
if subprocess.run(self._udocker("inspect", UDOCKER_BASE), capture_output=True).returncode:
|
|
127
|
+
self._step(self._udocker("pull", UDOCKER_BASE))
|
|
128
|
+
self._step(self._udocker("create", f"--name={self.name}", UDOCKER_BASE))
|
|
129
|
+
script = (f"set -e; {self._install()}; "
|
|
130
|
+
"cd /raid-setup && DEBIAN_FRONTEND=noninteractive bash setup.sh; "
|
|
131
|
+
f"echo {self.hash} > /etc/raid-setup.sha256")
|
|
132
|
+
try:
|
|
133
|
+
self._step(self._udocker("run", "--user=root", f"-v={context}:/raid-setup", self.name,
|
|
134
|
+
"/bin/bash", "-c", script), setup=True)
|
|
135
|
+
except ContainerError:
|
|
136
|
+
subprocess.run(self._udocker("rm", self.name), capture_output=True) # never reused half-built
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
def _step(self, cmd, setup=False):
|
|
140
|
+
"""Run a build command; on failure raise ContainerError with the end of its output. setup: the command
|
|
141
|
+
installs the packages and runs setup.sh."""
|
|
142
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
|
|
143
|
+
if proc.returncode:
|
|
144
|
+
tail = "\n".join(proc.stdout.decode(errors="replace").rstrip().splitlines()[-40:])
|
|
145
|
+
step = " ".join(c for c in cmd[:4] if c not in ("-q", "--allow-root"))
|
|
146
|
+
raise ContainerError(f"Building {self.name} failed in `{step} ...`:\n{tail}"
|
|
147
|
+
+ ("\nCheck setup.sh: it runs as root in the image build, with internet access." if setup else ""))
|
|
148
|
+
|
|
149
|
+
def _udocker(self, *args):
|
|
150
|
+
return ["udocker", "-q", *(["--allow-root"] if hasattr(os, "geteuid") and os.geteuid() == 0 else []), *args]
|
|
151
|
+
|
|
152
|
+
# ---------- running ----------
|
|
153
|
+
|
|
154
|
+
def call(self, function, dataset, solution, write=None):
|
|
155
|
+
"""Run function (score, method or artifact) of scoring/score.py on dataset in the container, with the
|
|
156
|
+
challenge directory at /job. Output goes to write line by line. Returns the runner's result dict."""
|
|
157
|
+
write = write or sys.stdout.write
|
|
158
|
+
inside = lambda p: "/job/" + p.relative_to(self.target).as_posix() if p != self.target else "/job" # noqa: E731
|
|
159
|
+
env = {"HOME": "/tmp", "PATH": PATH, "RAID_SOLUTION": inside(solution), "PYTHONDONTWRITEBYTECODE": "1",
|
|
160
|
+
**{k: v for k, v in os.environ.items() if PASSED_ENV.match(k)}}
|
|
161
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
162
|
+
shutil.copy(RUNNER, Path(tmp, "runner.py"))
|
|
163
|
+
command = ["python3", "-IS", "/raid-check/runner.py", function, inside(dataset), "/raid-check/result.json"]
|
|
164
|
+
if self.kind == "docker":
|
|
165
|
+
user = ["--user", f"{os.getuid()}:{os.getgid()}"] if hasattr(os, "getuid") else []
|
|
166
|
+
cmd = ["docker", "run", "--rm", "--init", *user, "-v", f"{self.target}:/job", "-v", f"{tmp}:/raid-check",
|
|
167
|
+
"-w", "/job", *[f"--env={k}={v}" for k, v in env.items()], self.name, *command]
|
|
168
|
+
else:
|
|
169
|
+
cmd = self._udocker("run", f"-v={self.target}:/job", f"-v={tmp}:/raid-check", "--workdir=/job",
|
|
170
|
+
*[f"--env={k}={v}" for k, v in env.items()], self.name, *command)
|
|
171
|
+
with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL) as proc:
|
|
172
|
+
for line in iter(proc.stdout.readline, b""):
|
|
173
|
+
write(line.decode(errors="replace"))
|
|
174
|
+
result = Path(tmp, "result.json")
|
|
175
|
+
if not result.is_file():
|
|
176
|
+
return {"exit": f"the container {self.name} exited with code {proc.returncode} before score.py finished"}
|
|
177
|
+
return json.loads(result.read_text())
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Runs inside the raid-<challenge> container: python3 -IS _runner.py <function> <dataset> <result.json>
|
|
2
|
+
|
|
3
|
+
Calls <function> (score, method or artifact) of scoring/score.py in the working directory (the challenge directory)
|
|
4
|
+
with the dataset path and writes {"value": ...}, {"exit": ...} or {"error": ..., "traceback": ...} to result.json.
|
|
5
|
+
Like the backend, score.py sees only Debian's Python packages.
|
|
6
|
+
"""
|
|
7
|
+
import importlib.util
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import traceback
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
DEBIAN_PACKAGES = "/usr/lib/python3/dist-packages"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(function, dataset, out):
|
|
18
|
+
job = Path.cwd()
|
|
19
|
+
script = job / "scoring" / "score.py"
|
|
20
|
+
solution = os.environ.get("RAID_SOLUTION", str(job))
|
|
21
|
+
sys.dont_write_bytecode = True # no __pycache__/ in the challenge directory
|
|
22
|
+
sys.path.append(DEBIAN_PACKAGES)
|
|
23
|
+
sys.path[:0] = [str(script.parent), solution] # score.py imports the solution, e.g. `from solution import md5`
|
|
24
|
+
try:
|
|
25
|
+
spec = importlib.util.spec_from_file_location("score", script)
|
|
26
|
+
module = importlib.util.module_from_spec(spec)
|
|
27
|
+
spec.loader.exec_module(module)
|
|
28
|
+
value = getattr(module, function)(Path(dataset))
|
|
29
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
30
|
+
result = {"error": "TypeError", "traceback": f"{function}() must return a number, not {value!r}"}
|
|
31
|
+
else:
|
|
32
|
+
result = {"value": float(value)}
|
|
33
|
+
except SystemExit as e: # score.py stops with sys.exit("why"), e.g. when the solution does not compile
|
|
34
|
+
result = {"exit": e.code if isinstance(e.code, str) else f"exit code {e.code}"}
|
|
35
|
+
except BaseException as e:
|
|
36
|
+
frames = [f for f in traceback.extract_tb(e.__traceback__) if job in Path(f.filename).resolve().parents]
|
|
37
|
+
result = {"error": type(e).__name__,
|
|
38
|
+
"traceback": "".join(traceback.format_list(frames) + traceback.format_exception_only(e)).rstrip()}
|
|
39
|
+
sys.stdout.flush()
|
|
40
|
+
sys.stderr.flush()
|
|
41
|
+
with open(out, "w") as f:
|
|
42
|
+
json.dump(result, f)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main(*sys.argv[1:4])
|
|
@@ -5,8 +5,9 @@ import sys
|
|
|
5
5
|
from . import Raid, RaidError
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def _run(description, action, key=True, options=(), target=("challenge", "challenge name, e.g. self-assessment")
|
|
9
|
-
|
|
8
|
+
def _run(description, action, key=True, options=(), target=("challenge", "challenge name, e.g. self-assessment"),
|
|
9
|
+
parser=None):
|
|
10
|
+
parser = parser or argparse.ArgumentParser(description=description)
|
|
10
11
|
if key:
|
|
11
12
|
parser.add_argument("--api-key", help="your API key (default: $RAID_KEY)")
|
|
12
13
|
for flag, text in options:
|
|
@@ -26,13 +27,32 @@ def fetch():
|
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
def check():
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
"""task check [method|artifact] <challenge>: the method, the artifact or both (first the method)."""
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
description="Check ./<challenge>/ locally on its data in the container raid-<challenge>, built like our "
|
|
33
|
+
"backend's with the challenge's setup.sh. `task check method <challenge>` runs your method, "
|
|
34
|
+
"`task check artifact <challenge>` scores your artifact, `task check <challenge>` does both.",
|
|
35
|
+
usage="%(prog)s [-h] [options] [method|artifact] challenge")
|
|
36
|
+
parser.add_argument("names", nargs="+", metavar="[method|artifact] challenge", help=argparse.SUPPRESS)
|
|
37
|
+
|
|
38
|
+
def action(raid, a):
|
|
39
|
+
names = a.names
|
|
40
|
+
if len(names) == 2 and names[1] in ("method", "artifact") and names[0] not in ("method", "artifact"):
|
|
41
|
+
names = names[::-1] # task check dawn method
|
|
42
|
+
if len(names) > 2 or len(names) == 2 and names[0] not in ("method", "artifact"):
|
|
43
|
+
parser.error("expected [method|artifact] <challenge>")
|
|
44
|
+
part, challenge = (None, names[0]) if len(names) == 1 else names
|
|
45
|
+
raid.check(challenge, part, a.hidden, a.reference, container=not a.no_container, rebuild=a.rebuild)
|
|
46
|
+
|
|
47
|
+
_run(None, action, key=False, target=None, parser=parser, options=[
|
|
48
|
+
("--rebuild", "build the container raid-<challenge> again even if setup.sh did not change"),
|
|
49
|
+
("--no-container", "check on this machine instead of in the container (needs the challenge's packages)"),
|
|
50
|
+
("--hidden", "challenge authors: score on hidden/ instead of the data"),
|
|
51
|
+
("--reference", "challenge authors: score reference/ instead of the student files")])
|
|
32
52
|
|
|
33
53
|
|
|
34
54
|
def submit():
|
|
35
|
-
_run("Submit ./<challenge>/ for scoring (without data
|
|
55
|
+
_run("Submit ./<challenge>/ for scoring (without its data, scoring/ and .raidignore patterns).",
|
|
36
56
|
lambda raid, a: raid.submit(a.challenge))
|
|
37
57
|
|
|
38
58
|
|
mlsec_raid-0.4.2/PKG-INFO
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.3
|
|
2
|
-
Name: mlsec-raid
|
|
3
|
-
Version: 0.4.2
|
|
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
|
|
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
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
Every command also works as `task <command>`, e.g. `task submit <challenge>`.
|
|
25
|
-
|
|
26
|
-
The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
|
|
27
|
-
|
|
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.
|
|
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`).
|
mlsec_raid-0.4.2/README.md
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
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
|
|
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
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
Every command also works as `task <command>`, e.g. `task submit <challenge>`.
|
|
18
|
-
|
|
19
|
-
The same is available in Python: `Raid(api_key).fetch(...)`, `.check(...)`, `.submit(...)`, `.push(...)`, `.wait(id)`, `.status()`, `.log(id)`, `.howto()`.
|
|
20
|
-
|
|
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.
|
|
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`).
|