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 +56 -0
- codabench/__main__.py +8 -0
- codabench/auth.py +133 -0
- codabench/cli.py +428 -0
- codabench/client.py +423 -0
- codabench/competitions.py +200 -0
- codabench/downloads.py +80 -0
- codabench/errors.py +29 -0
- codabench/text.py +59 -0
- codabench_client-0.1.0.dist-info/METADATA +258 -0
- codabench_client-0.1.0.dist-info/RECORD +15 -0
- codabench_client-0.1.0.dist-info/WHEEL +5 -0
- codabench_client-0.1.0.dist-info/entry_points.txt +2 -0
- codabench_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- codabench_client-0.1.0.dist-info/top_level.txt +1 -0
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
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())
|