codabench-client 0.1.0__py3-none-any.whl

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.
codabench/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """A small, readable Python client for the `Codabench <https://www.codabench.org/>`_ API.
2
+
3
+ Typical use::
4
+
5
+ from codabench import CodabenchClient
6
+
7
+ client = CodabenchClient() # reads .env / environment
8
+ competition = client.competition(17363) # URL or id both work
9
+ for f in collect_files(competition):
10
+ client.download_dataset(f.key, "input/", f.filename)
11
+
12
+ Every command in the ``codabench`` CLI is a thin wrapper over these methods —
13
+ see ``examples/`` for the same flows written as standalone scripts.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .auth import Credentials, DEFAULT_URL, load_dotenv
19
+ from .client import ARTIFACTS, CodabenchClient, TERMINAL_STATES, default_env_path
20
+ from .competitions import (
21
+ CompetitionFile,
22
+ collect_files,
23
+ description_markdown,
24
+ find_phase,
25
+ pages,
26
+ parse_competition_id,
27
+ phases,
28
+ primary_metric,
29
+ save_description,
30
+ )
31
+ from .errors import ApiError, AuthError, CodabenchError
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ __all__ = [
36
+ "ARTIFACTS",
37
+ "ApiError",
38
+ "AuthError",
39
+ "CodabenchClient",
40
+ "CodabenchError",
41
+ "CompetitionFile",
42
+ "Credentials",
43
+ "DEFAULT_URL",
44
+ "TERMINAL_STATES",
45
+ "__version__",
46
+ "collect_files",
47
+ "default_env_path",
48
+ "description_markdown",
49
+ "find_phase",
50
+ "load_dotenv",
51
+ "pages",
52
+ "parse_competition_id",
53
+ "phases",
54
+ "primary_metric",
55
+ "save_description",
56
+ ]
codabench/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m codabench ...`` next to the installed ``codabench`` script."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
codabench/auth.py ADDED
@@ -0,0 +1,133 @@
1
+ """Credentials and the two authentication schemes Codabench uses.
2
+
3
+ Codabench answers to two different mechanisms, and you need both:
4
+
5
+ * **Token auth** (``/api/api-token-auth/``) for everything under ``/api/``.
6
+ * **Session auth** (a Django form login at ``/accounts/login/``) for
7
+ ``/datasets/download/<key>/`` — the route behind a competition's "Files"
8
+ tab, which is a plain Django view and ignores the API token.
9
+
10
+ Both read the same credentials, so a caller never has to care which one a
11
+ given endpoint needs.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from dataclasses import dataclass
18
+ from urllib.parse import urljoin
19
+
20
+ import requests
21
+
22
+ from .errors import AuthError
23
+
24
+ DEFAULT_URL = "https://www.codabench.org/"
25
+
26
+
27
+ def load_dotenv(path: str) -> None:
28
+ """Load ``KEY=VALUE`` lines from ``path`` into the environment.
29
+
30
+ Supports optional quotes and ``#`` comments. Existing environment
31
+ variables take precedence, so the real environment always wins over a file.
32
+ """
33
+ if not os.path.isfile(path):
34
+ return
35
+ with open(path, encoding="utf-8") as fh:
36
+ for line in fh:
37
+ line = line.strip()
38
+ if not line or line.startswith("#") or "=" not in line:
39
+ continue
40
+ key, _, value = line.partition("=")
41
+ os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
42
+
43
+
44
+ @dataclass
45
+ class Credentials:
46
+ """A Codabench account and the instance it belongs to.
47
+
48
+ Accounts are per-instance: ``dev.codabench.org`` logins are separate from
49
+ ``www.codabench.org`` ones.
50
+ """
51
+
52
+ username: "str | None" = None
53
+ password: "str | None" = None
54
+ base_url: str = DEFAULT_URL
55
+
56
+ @classmethod
57
+ def from_env(cls, base_url: "str | None" = None, username: "str | None" = None,
58
+ password: "str | None" = None, env_path: "str | None" = None) -> "Credentials":
59
+ """Build from explicit values, falling back to ``.env`` / the environment.
60
+
61
+ Reads ``CODABENCH_URL``, ``CODABENCH_USERNAME`` and ``CODABENCH_PASSWORD``.
62
+ """
63
+ if env_path:
64
+ load_dotenv(env_path)
65
+ return cls(
66
+ username=username or os.environ.get("CODABENCH_USERNAME"),
67
+ password=password or os.environ.get("CODABENCH_PASSWORD"),
68
+ base_url=base_url or os.environ.get("CODABENCH_URL", DEFAULT_URL),
69
+ )
70
+
71
+ @property
72
+ def complete(self) -> bool:
73
+ """True when both a username and a password are available."""
74
+ return bool(self.username and self.password)
75
+
76
+ def require(self) -> None:
77
+ """Raise :class:`AuthError` unless credentials are set."""
78
+ if not self.complete:
79
+ raise AuthError(
80
+ "missing credentials: set CODABENCH_USERNAME and CODABENCH_PASSWORD "
81
+ "in .env or the environment (or pass --username/--password)."
82
+ )
83
+
84
+
85
+ def token_headers(credentials: Credentials, timeout: int = 30) -> dict:
86
+ """Exchange credentials for ``{'Authorization': 'Token ...'}``.
87
+
88
+ Returns empty headers when no credentials are configured: public
89
+ competitions are readable anonymously, so that is a valid mode rather
90
+ than an error. Wrong credentials, on the other hand, raise.
91
+ """
92
+ if not credentials.complete:
93
+ return {}
94
+ resp = requests.post(
95
+ urljoin(credentials.base_url, "/api/api-token-auth/"),
96
+ {"username": credentials.username, "password": credentials.password},
97
+ timeout=timeout,
98
+ )
99
+ if resp.status_code != 200:
100
+ raise AuthError(
101
+ f"login failed ({resp.status_code}) for {credentials.base_url}. "
102
+ "Check CODABENCH_USERNAME / CODABENCH_PASSWORD — note that accounts "
103
+ "on dev.codabench.org and www.codabench.org are separate."
104
+ )
105
+ return {"Authorization": f"Token {resp.json()['token']}"}
106
+
107
+
108
+ def open_session(credentials: Credentials, timeout: int = 30) -> "requests.Session | None":
109
+ """Log in like a browser and return the cookie-carrying session.
110
+
111
+ Returns None when credentials are missing or the login form rejects them;
112
+ callers treat that as "file downloads unavailable" rather than fatal,
113
+ since the rest of the API still works with token auth.
114
+ """
115
+ if not credentials.complete:
116
+ return None
117
+ session = requests.Session()
118
+ login_url = urljoin(credentials.base_url, "/accounts/login/")
119
+ resp = session.get(login_url, timeout=timeout)
120
+ csrf = session.cookies.get("csrftoken")
121
+ if resp.status_code != 200 or not csrf:
122
+ return None
123
+ resp = session.post(
124
+ login_url,
125
+ data={"username": credentials.username, "password": credentials.password,
126
+ "csrfmiddlewaretoken": csrf},
127
+ headers={"Referer": login_url},
128
+ timeout=timeout,
129
+ )
130
+ # A successful Django form login redirects away; landing back on the form is a failure.
131
+ if resp.status_code != 200 or "/accounts/login" in resp.url:
132
+ return None
133
+ return session
codabench/cli.py ADDED
@@ -0,0 +1,428 @@
1
+ """The ``codabench`` command line.
2
+
3
+ Each subcommand is a thin wrapper over :class:`codabench.client.CodabenchClient`;
4
+ this module owns argument parsing and terminal output, nothing else.
5
+
6
+ codabench competitions list competitions
7
+ codabench show 17363 one competition, in full
8
+ codabench files 16161 list/download the Files tab
9
+ codabench submit 17525 -z run.zip --wait submit and watch
10
+ codabench outputs 17525 fetch a submission's outputs
11
+ codabench rerun --submission 42 re-run on another task (bots)
12
+ codabench create -b bundle.zip create a competition
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from . import __version__
23
+ from .client import ARTIFACTS, CodabenchClient, default_env_path
24
+ from .competitions import (
25
+ collect_files,
26
+ find_phase,
27
+ pages,
28
+ parse_competition_id,
29
+ phases,
30
+ primary_metric,
31
+ save_description,
32
+ )
33
+ from .downloads import save_json
34
+ from .errors import CodabenchError
35
+ from .text import human_size, strip_html, truncate
36
+
37
+
38
+ # ---- output helpers ---------------------------------------------------------
39
+ def _rule(title: str = "", width: int = 74) -> None:
40
+ print(f"\n{title}" if title else "")
41
+ print("-" * width)
42
+
43
+
44
+ def _client(args: argparse.Namespace) -> CodabenchClient:
45
+ return CodabenchClient(base_url=args.url, username=args.username,
46
+ password=args.password, env_path=args.env)
47
+
48
+
49
+ # ---- commands ---------------------------------------------------------------
50
+ def cmd_competitions(args: argparse.Namespace) -> None:
51
+ """List the competitions visible to this account."""
52
+ competitions = _client(args).list_competitions(args.search)
53
+ _rule("Competitions")
54
+ print(f"{'id':>7} {'organizer':<22} title")
55
+ for comp in competitions:
56
+ organizer = str(comp.get("created_by", ""))[:22]
57
+ print(f"{comp.get('id', ''):>7} {organizer:<22} {comp.get('title', '')}")
58
+ print(f"\n{len(competitions)} competition(s). Details: codabench show <id>\n")
59
+
60
+
61
+ def cmd_show(args: argparse.Namespace) -> None:
62
+ """Show one competition: pages, phases, files, leaderboard."""
63
+ client = _client(args)
64
+ comp = client.competition(args.competition)
65
+
66
+ if args.json:
67
+ print(json.dumps(comp, indent=2, ensure_ascii=False))
68
+ return
69
+
70
+ print(f"\n{'=' * 74}\n{comp.get('title', '?')}\n{'=' * 74}")
71
+ print(f" id : {comp.get('id')}")
72
+ print(f" organizer : {comp.get('owner_display_name') or comp.get('created_by')}")
73
+ print(f" created : {str(comp.get('created_when', ''))[:10]}")
74
+ print(f" participants : {comp.get('participants_count')}")
75
+ print(f" submissions : {comp.get('submissions_count')}")
76
+ print(f" docker image : {comp.get('docker_image')}")
77
+ print(f" published : input_data={comp.get('make_input_data_available')} "
78
+ f"programs={comp.get('make_programs_available')}")
79
+
80
+ competition_pages = pages(comp)
81
+ if competition_pages:
82
+ _rule(f"Pages ({len(competition_pages)})")
83
+ for page in competition_pages:
84
+ body = strip_html(page.get("content") or "")
85
+ if args.pages:
86
+ print(f"\n## {page.get('title', '?')}\n\n{body}")
87
+ else:
88
+ print(f" {page.get('index', '?'):>2}. {str(page.get('title', '?')):<24} "
89
+ f"{truncate(body, 90)}")
90
+
91
+ competition_phases = phases(comp)
92
+ _rule(f"Phases ({len(competition_phases)})")
93
+ for phase in competition_phases:
94
+ print(f"\n phase id={phase.get('id')} index={phase.get('index')} "
95
+ f"status={phase.get('status')!r} name={phase.get('name')!r}")
96
+ print(f" window : {str(phase.get('start', ''))[:10]} -> "
97
+ f"{str(phase.get('end', ''))[:10]}")
98
+ print(f" quota : {phase.get('max_submissions_per_day')}/day, "
99
+ f"{phase.get('max_submissions_per_person')} total "
100
+ f"(used: {phase.get('used_submissions_per_person', '?')})")
101
+ for task in phase.get("tasks", []):
102
+ print(f" task id={task.get('id')} {task.get('name')!r}")
103
+
104
+ files = collect_files(comp)
105
+ _rule(f"Files ({len(files)})")
106
+ for entry in files or []:
107
+ print(f" {entry.type:<18} {human_size(entry.size):>10} {entry.name}")
108
+ if not files:
109
+ print(" (none listed)")
110
+
111
+ metric = primary_metric(comp)
112
+ if metric:
113
+ _rule(f"Leaderboard: {metric['leaderboard']}")
114
+ print(f" primary metric : {metric['title']} ({metric['key']}), "
115
+ f"{'higher' if metric['higher_is_better'] else 'lower'} is better")
116
+ board = client.leaderboard(metric["leaderboard_id"]) or {}
117
+ submissions = board.get("submissions") or []
118
+ print(f" entries : {len(submissions)}")
119
+ scores = []
120
+ for submission in submissions:
121
+ for score in submission.get("scores", []):
122
+ if score.get("is_primary") or score.get("column_key") == metric["key"]:
123
+ try:
124
+ scores.append((float(score["score"]), submission.get("owner")))
125
+ except (TypeError, ValueError, KeyError):
126
+ pass
127
+ break
128
+ if scores:
129
+ best = max(scores) if metric["higher_is_better"] else min(scores)
130
+ print(f" current best : {best[0]:.5f} by {best[1]}")
131
+
132
+ if args.save_dir:
133
+ print()
134
+ for path in save_description(comp, args.save_dir):
135
+ print(f"[ok] {path}")
136
+ if not args.no_download:
137
+ _download_files(client, files, Path(args.save_dir) / "input", extract=True)
138
+ print()
139
+
140
+
141
+ def _download_files(client: CodabenchClient, files: list, out_dir: "str | Path",
142
+ extract: bool) -> int:
143
+ """Download every file the account is allowed to fetch. Returns the count.
144
+
145
+ Files Codabench denies (reference data, unpublished programs) are skipped
146
+ silently — that is the platform's policy, not a failure of this tool.
147
+ """
148
+ if not files:
149
+ return 0
150
+ if client.session() is None:
151
+ print("[!!] cannot download files: no login. Set CODABENCH_USERNAME / "
152
+ "CODABENCH_PASSWORD to fetch the starting kit and public data.")
153
+ return 0
154
+ saved = 0
155
+ for entry in files:
156
+ path = client.download_dataset(entry.key, out_dir, entry.filename, extract)
157
+ if path is not None:
158
+ saved += 1
159
+ print(f"[ok] {entry.type:<18} -> {path}")
160
+ if not saved:
161
+ print("[!!] no files were downloadable for this account.")
162
+ return saved
163
+
164
+
165
+ def cmd_files(args: argparse.Namespace) -> None:
166
+ """List or download a competition's files."""
167
+ client = _client(args)
168
+ comp = client.competition(args.competition)
169
+ files = collect_files(comp)
170
+ if not files:
171
+ raise CodabenchError(f"competition {comp.get('id')} lists no files.")
172
+
173
+ if args.only:
174
+ patterns = [p.lower() for p in args.only]
175
+ files = [f for f in files
176
+ if any(p in f.type.lower() or p in f.name.lower() for p in patterns)]
177
+ if not files:
178
+ raise CodabenchError(f"no files match --only {args.only}.")
179
+
180
+ _rule(f"Files — {comp.get('title', '?')}")
181
+ print(f"{'type':<18} {'size':>10} {'phase / task':<26} name")
182
+ for entry in files:
183
+ print(f"{entry.type:<18} {human_size(entry.size):>10} "
184
+ f"{entry.location[:26]:<26} {entry.name}")
185
+ print()
186
+ if args.list:
187
+ return
188
+
189
+ out_dir = Path(args.out_dir) / str(comp.get("id"))
190
+ saved = _download_files(client, files, out_dir, extract=not args.no_extract)
191
+ print(f"\n[ok] {saved}/{len(files)} file(s) -> {out_dir}/\n")
192
+
193
+
194
+ def cmd_submit(args: argparse.Namespace) -> None:
195
+ """Upload a zip to a phase, optionally waiting for the score."""
196
+ client = _client(args)
197
+ if args.phase:
198
+ phase_id = args.phase
199
+ else:
200
+ phase = find_phase(client.competition(args.competition), phase_index=args.phase_index)
201
+ phase_id = phase["id"]
202
+ print(f"[ok] phase id={phase_id} ({phase.get('name')!r})")
203
+
204
+ client.can_submit(phase_id)
205
+ print(f"[ok] phase {phase_id} accepts a submission.")
206
+ submission = client.submit(phase_id, args.zip, args.tasks)
207
+ submission_id = submission.get("id")
208
+ print(f"[ok] submission created: id={submission_id} status={submission.get('status')}")
209
+
210
+ if not (args.wait or args.results_json or args.download_dir):
211
+ return
212
+
213
+ print(f"[..] polling every {args.interval}s (timeout {args.timeout}s) ...")
214
+ finished = client.wait_for_submission(submission_id, args.interval, args.timeout)
215
+ results = client.results(submission_id, finished)
216
+ _print_results(results)
217
+ if args.results_json:
218
+ print(f"[ok] results -> {save_json(results, args.results_json)}")
219
+ if args.download_dir:
220
+ _download_artifacts(client, submission_id, Path(args.download_dir) / str(submission_id),
221
+ list(ARTIFACTS), extract=not args.no_extract)
222
+
223
+
224
+ def _print_results(results: dict) -> None:
225
+ _rule(f"Results — submission {results['submission_id']} ({results['status']})")
226
+ if results.get("primary"):
227
+ print(f" primary: {results['primary']['metric']} = {results['primary']['score']}")
228
+ for name, value in (results.get("metrics") or {}).items():
229
+ print(f" {name}: {value}")
230
+ if not results.get("metrics"):
231
+ print(" (no metrics returned)")
232
+ print()
233
+
234
+
235
+ def _download_artifacts(client: CodabenchClient, submission_id: int, out_dir: Path,
236
+ wanted: list, extract: bool) -> None:
237
+ """Download the chosen artifacts of one submission into ``out_dir``."""
238
+ details = client.submission_details(submission_id)
239
+ for artifact in wanted:
240
+ path = client.download_artifact(submission_id, artifact, out_dir, details, extract)
241
+ if path is None:
242
+ print(f"[!!] no {artifact} artifact on submission {submission_id}.")
243
+ else:
244
+ print(f"[ok] {artifact:<11} -> {path}")
245
+ print(f"[ok] details -> {save_json(details, out_dir / 'details.json')}")
246
+
247
+
248
+ def cmd_outputs(args: argparse.Namespace) -> None:
249
+ """Download a submission's files: submission zip, prediction & scoring output."""
250
+ client = _client(args)
251
+ submission_id = args.submission
252
+ if submission_id is None:
253
+ phase_id = args.phase or find_phase(client.competition(args.competition))["id"]
254
+ submissions = client.list_submissions(phase_id)
255
+ if not submissions:
256
+ raise CodabenchError(f"no submissions on phase {phase_id} for this account.")
257
+ _rule(f"Submissions on phase {phase_id}")
258
+ print(f"{'id':>8} {'status':<10} {'created (UTC)':<20} filename")
259
+ for sub in submissions:
260
+ print(f"{sub.get('id', ''):>8} {str(sub.get('status', '')):<10} "
261
+ f"{str(sub.get('created_when', ''))[:19]:<20} {sub.get('filename', '')}")
262
+ print()
263
+ if args.list:
264
+ return
265
+ picked = client.pick_submission(phase_id, args.last)
266
+ submission_id = picked["id"]
267
+ label = "latest" if args.last == 1 else f"#{args.last} most recent"
268
+ print(f"[ok] using {label} submission id={submission_id} "
269
+ f"(status={picked.get('status')}).")
270
+
271
+ out_dir = Path(args.out_dir) / str(submission_id)
272
+ _download_artifacts(client, submission_id, out_dir, args.only, extract=not args.no_extract)
273
+ print()
274
+
275
+
276
+ def cmd_rerun(args: argparse.Namespace) -> None:
277
+ """Re-run an existing submission on another task (robot accounts)."""
278
+ client = _client(args)
279
+ if args.submission and args.task:
280
+ result = client.rerun_submission(args.submission, args.task)
281
+ print(f"[ok] re-ran submission {args.submission} on task {args.task} "
282
+ f"-> new submission {result.get('id')}")
283
+ return
284
+ if args.submission:
285
+ tasks = client.list_tasks()
286
+ _rule("Tasks")
287
+ print(f"{'key':<38} name")
288
+ for task in tasks:
289
+ print(f"{task.get('key', ''):<38} {str(task.get('name', ''))[:32]}")
290
+ print(f"\nRe-run with: codabench rerun --submission {args.submission} --task <key>\n")
291
+ return
292
+ submissions = client.list_submissions()
293
+ _rule("Submissions")
294
+ print(f"{'id':>8} {'owner':<20} task")
295
+ for sub in submissions:
296
+ task = sub.get("task")
297
+ print(f"{sub.get('id', ''):>8} {str(sub.get('owner', '')):<20} "
298
+ f"{task.get('id') if isinstance(task, dict) else task}")
299
+ print("\nPick one: codabench rerun --submission <id>\n")
300
+
301
+
302
+ def cmd_create(args: argparse.Namespace) -> None:
303
+ """Create a competition from a bundle zip."""
304
+ client = _client(args)
305
+ print(f"[..] target: {client.base_url}")
306
+ result = client.create_competition(args.bundle, args.interval, args.timeout,
307
+ wait=not args.no_wait)
308
+ if result["competition_id"]:
309
+ print(f"[ok] competition created: id={result['competition_id']}")
310
+ print(f" {client.url('/competitions/' + str(result['competition_id']) + '/')}")
311
+ else:
312
+ print(f"[ok] not waiting. Poll: GET "
313
+ f"{client.url('/api/competitions/' + str(result['status_id']) + '/creation_status/')}")
314
+
315
+
316
+ # ---- argument parsing -------------------------------------------------------
317
+ def build_parser() -> argparse.ArgumentParser:
318
+ parser = argparse.ArgumentParser(
319
+ prog="codabench",
320
+ description="Talk to the Codabench API: browse competitions, download "
321
+ "files, submit, and fetch results.",
322
+ )
323
+ parser.add_argument("--version", action="version", version=f"codabench {__version__}")
324
+
325
+ common = argparse.ArgumentParser(add_help=False)
326
+ common.add_argument("--url", help="Codabench base URL (or set CODABENCH_URL).")
327
+ common.add_argument("--username", help="Username (or set CODABENCH_USERNAME).")
328
+ common.add_argument("--password", help="Password (or set CODABENCH_PASSWORD).")
329
+ common.add_argument("--env", default=default_env_path(),
330
+ help="Path to the .env file holding your credentials.")
331
+
332
+ sub = parser.add_subparsers(dest="command", required=True)
333
+
334
+ p = sub.add_parser("competitions", parents=[common], help="List competitions.")
335
+ p.add_argument("--search", help="Filter by title/keyword (the full listing is slow).")
336
+ p.set_defaults(func=cmd_competitions)
337
+
338
+ p = sub.add_parser("show", parents=[common],
339
+ help="Show one competition: pages, phases, files, leaderboard.")
340
+ p.add_argument("competition", help="Competition URL or id.")
341
+ p.add_argument("--pages", action="store_true", help="Print every page in full.")
342
+ p.add_argument("--save-dir", help="Write description.md, pages/*.md and input/ here.")
343
+ p.add_argument("--no-download", action="store_true",
344
+ help="With --save-dir: save the text only, skip file downloads.")
345
+ p.add_argument("--json", action="store_true", help="Print the raw API payload.")
346
+ p.set_defaults(func=cmd_show)
347
+
348
+ p = sub.add_parser("files", parents=[common],
349
+ help="List or download a competition's files.")
350
+ p.add_argument("competition", help="Competition URL or id.")
351
+ p.add_argument("--list", action="store_true", help="List only, do not download.")
352
+ p.add_argument("--only", nargs="+", metavar="PATTERN",
353
+ help="Only files whose type or name contains one of these.")
354
+ p.add_argument("-o", "--out-dir", default="files",
355
+ help="Download into <out-dir>/<competition id>/.")
356
+ p.add_argument("--no-extract", action="store_true", help="Keep archives zipped.")
357
+ p.set_defaults(func=cmd_files)
358
+
359
+ p = sub.add_parser("submit", parents=[common], help="Submit a zip to a phase.")
360
+ p.add_argument("competition", nargs="?", help="Competition URL or id (resolves the phase).")
361
+ p.add_argument("-z", "--zip", required=True, help="Submission .zip to upload.")
362
+ p.add_argument("-p", "--phase", type=int, help="Phase id (skips competition lookup).")
363
+ p.add_argument("--phase-index", type=int, help="With a competition: pick the phase by index.")
364
+ p.add_argument("-t", "--tasks", type=int, nargs="*", default=[],
365
+ help="Task ids to target (default: all tasks on the phase).")
366
+ p.add_argument("--wait", action="store_true", help="Poll until the submission is scored.")
367
+ p.add_argument("--interval", type=int, default=15, help="Seconds between polls.")
368
+ p.add_argument("--timeout", type=int, default=1800, help="Max seconds to wait.")
369
+ p.add_argument("--results-json", help="Write the structured results here (implies --wait).")
370
+ p.add_argument("--download-dir",
371
+ help="Download the outputs here when finished (implies --wait).")
372
+ p.add_argument("--no-extract", action="store_true", help="Keep archives zipped.")
373
+ p.set_defaults(func=cmd_submit)
374
+
375
+ p = sub.add_parser("outputs", parents=[common],
376
+ help="Download a submission's prediction/scoring output.")
377
+ p.add_argument("competition", nargs="?", help="Competition URL or id (uses its phase).")
378
+ p.add_argument("-s", "--submission", type=int, help="Submission id.")
379
+ p.add_argument("-p", "--phase", type=int, help="Phase id.")
380
+ p.add_argument("-l", "--last", type=int, default=1, metavar="N",
381
+ help="Pick the Nth most recent submission (1 = latest).")
382
+ p.add_argument("--list", action="store_true", help="List your submissions and exit.")
383
+ p.add_argument("--only", nargs="+", default=["submission", "prediction", "scoring"],
384
+ choices=list(ARTIFACTS), help="Which artifacts to download.")
385
+ p.add_argument("-o", "--out-dir", default="outputs",
386
+ help="Download into <out-dir>/<submission id>/.")
387
+ p.add_argument("--no-extract", action="store_true", help="Keep archives zipped.")
388
+ p.set_defaults(func=cmd_outputs)
389
+
390
+ p = sub.add_parser("rerun", parents=[common],
391
+ help="Re-run a submission on another task (robot accounts).")
392
+ p.add_argument("-s", "--submission", type=int, help="Submission id to re-run.")
393
+ p.add_argument("-t", "--task", help="Task key (UUID) to run it on.")
394
+ p.set_defaults(func=cmd_rerun)
395
+
396
+ p = sub.add_parser("create", parents=[common],
397
+ help="Create a competition from a bundle zip.")
398
+ p.add_argument("-b", "--bundle", required=True, help="Competition bundle .zip.")
399
+ p.add_argument("--interval", type=int, default=5, help="Seconds between status polls.")
400
+ p.add_argument("--timeout", type=int, default=900, help="Max seconds to wait for unpacking.")
401
+ p.add_argument("--no-wait", action="store_true", help="Start creation and exit.")
402
+ p.set_defaults(func=cmd_create)
403
+
404
+ return parser
405
+
406
+
407
+ def main(argv: "list | None" = None) -> int:
408
+ args = build_parser().parse_args(argv if argv is not None else sys.argv[1:])
409
+ if getattr(args, "command", None) == "submit" and not (args.phase or args.competition):
410
+ print("error: submit needs a competition or --phase.", file=sys.stderr)
411
+ return 2
412
+ if getattr(args, "command", None) == "outputs" and not (
413
+ args.submission or args.phase or args.competition):
414
+ print("error: outputs needs a competition, --phase, or --submission.", file=sys.stderr)
415
+ return 2
416
+ try:
417
+ args.func(args)
418
+ except CodabenchError as exc:
419
+ print(f"error: {exc}", file=sys.stderr)
420
+ return 1
421
+ except KeyboardInterrupt:
422
+ print("\ninterrupted.", file=sys.stderr)
423
+ return 130
424
+ return 0
425
+
426
+
427
+ if __name__ == "__main__":
428
+ sys.exit(main())