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/client.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""The Codabench API client.
|
|
2
|
+
|
|
3
|
+
One object covers the whole surface these scripts need:
|
|
4
|
+
|
|
5
|
+
=============================== ==========================================
|
|
6
|
+
What you want Method
|
|
7
|
+
=============================== ==========================================
|
|
8
|
+
Browse competitions :meth:`~CodabenchClient.list_competitions`,
|
|
9
|
+
:meth:`~CodabenchClient.competition`
|
|
10
|
+
Download the "Files" tab :meth:`~CodabenchClient.download_dataset`
|
|
11
|
+
Submit a zip :meth:`~CodabenchClient.submit`
|
|
12
|
+
Watch a submission :meth:`~CodabenchClient.wait_for_submission`
|
|
13
|
+
Read scores + artifacts :meth:`~CodabenchClient.results`
|
|
14
|
+
Download submission artifacts :meth:`~CodabenchClient.download_artifact`
|
|
15
|
+
Create a competition :meth:`~CodabenchClient.create_competition`
|
|
16
|
+
=============================== ==========================================
|
|
17
|
+
|
|
18
|
+
Authentication is lazy: nothing logs in until a call needs it, so reading a
|
|
19
|
+
public competition works with no credentials at all.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from urllib.parse import urljoin, urlparse
|
|
28
|
+
|
|
29
|
+
import requests
|
|
30
|
+
|
|
31
|
+
from .auth import Credentials, open_session, token_headers
|
|
32
|
+
from .competitions import find_phase, parse_competition_id
|
|
33
|
+
from .downloads import read_scores, save_bytes
|
|
34
|
+
from .errors import ApiError, CodabenchError
|
|
35
|
+
|
|
36
|
+
#: Submission states that will not change again.
|
|
37
|
+
TERMINAL_STATES = {"Finished", "Failed", "Cancelled"}
|
|
38
|
+
|
|
39
|
+
#: Artifact name -> key in the ``get_details`` payload. These are the rows of
|
|
40
|
+
#: the submission download panel in the web UI.
|
|
41
|
+
ARTIFACTS = {
|
|
42
|
+
"submission": "data_file", # "Submission File"
|
|
43
|
+
"prediction": "prediction_result", # "Output from prediction step"
|
|
44
|
+
"scoring": "scoring_result", # "Output from scoring step"
|
|
45
|
+
"detailed": "detailed_result", # "Detailed results"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CodabenchClient:
|
|
50
|
+
"""HTTP client for one Codabench instance.
|
|
51
|
+
|
|
52
|
+
:param base_url: instance URL; defaults to ``CODABENCH_URL`` or codabench.org.
|
|
53
|
+
:param username: defaults to ``CODABENCH_USERNAME``.
|
|
54
|
+
:param password: defaults to ``CODABENCH_PASSWORD``.
|
|
55
|
+
:param env_path: optional ``.env`` file to load before reading the above.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, base_url: "str | None" = None, username: "str | None" = None,
|
|
59
|
+
password: "str | None" = None, env_path: "str | None" = None,
|
|
60
|
+
timeout: int = 60) -> None:
|
|
61
|
+
self.credentials = Credentials.from_env(base_url, username, password, env_path)
|
|
62
|
+
self.base_url = self.credentials.base_url
|
|
63
|
+
self.timeout = timeout
|
|
64
|
+
self._headers: "dict | None" = None
|
|
65
|
+
self._session: "requests.Session | None" = None
|
|
66
|
+
|
|
67
|
+
# ---- plumbing ---------------------------------------------------------
|
|
68
|
+
@property
|
|
69
|
+
def headers(self) -> dict:
|
|
70
|
+
"""Token auth headers, fetched once on first use (``{}`` if anonymous)."""
|
|
71
|
+
if self._headers is None:
|
|
72
|
+
self._headers = token_headers(self.credentials)
|
|
73
|
+
return self._headers
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def authenticated(self) -> bool:
|
|
77
|
+
"""True when the client holds an API token."""
|
|
78
|
+
return bool(self.headers)
|
|
79
|
+
|
|
80
|
+
def session(self) -> "requests.Session | None":
|
|
81
|
+
"""Cookie session for the Files-tab download route (None if unavailable).
|
|
82
|
+
|
|
83
|
+
See :func:`codabench.auth.open_session` for why this exists next to the
|
|
84
|
+
API token.
|
|
85
|
+
"""
|
|
86
|
+
if self._session is None:
|
|
87
|
+
self._session = open_session(self.credentials)
|
|
88
|
+
return self._session
|
|
89
|
+
|
|
90
|
+
def url(self, path: str) -> str:
|
|
91
|
+
"""Absolute URL for an API path."""
|
|
92
|
+
return urljoin(self.base_url, path)
|
|
93
|
+
|
|
94
|
+
def request(self, method: str, path: str, timeout: "int | None" = None,
|
|
95
|
+
**kwargs) -> requests.Response:
|
|
96
|
+
"""Send a request, turning transport failures into :class:`ApiError`.
|
|
97
|
+
|
|
98
|
+
Keeps callers (and the CLI) free of ``requests`` exception handling.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
return requests.request(method, self.url(path), headers=self.headers,
|
|
102
|
+
timeout=timeout or self.timeout, **kwargs)
|
|
103
|
+
except requests.Timeout as exc:
|
|
104
|
+
raise ApiError(f"{method} {path} timed out after "
|
|
105
|
+
f"{timeout or self.timeout}s — the server is slow or "
|
|
106
|
+
"unreachable; retry or raise --timeout.") from exc
|
|
107
|
+
except requests.RequestException as exc:
|
|
108
|
+
raise ApiError(f"{method} {path} failed: {exc}") from exc
|
|
109
|
+
|
|
110
|
+
def get(self, path: str, timeout: "int | None" = None, **params) -> object:
|
|
111
|
+
"""GET a JSON endpoint. Raises :class:`ApiError` on a non-200 reply."""
|
|
112
|
+
resp = self.request("GET", path, timeout=timeout, params=params or None)
|
|
113
|
+
if resp.status_code != 200:
|
|
114
|
+
raise ApiError(f"GET {path} failed ({resp.status_code}): {resp.text[:300]}",
|
|
115
|
+
resp.status_code, resp.text)
|
|
116
|
+
return resp.json()
|
|
117
|
+
|
|
118
|
+
def try_get(self, path: str, **params) -> object:
|
|
119
|
+
"""Like :meth:`get`, but returns None instead of raising.
|
|
120
|
+
|
|
121
|
+
For endpoints that legitimately 403/404 depending on your role.
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
return self.get(path, **params)
|
|
125
|
+
except ApiError:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def post(self, path: str, data: "dict | None" = None, **params) -> object:
|
|
129
|
+
"""POST form data and return the JSON reply."""
|
|
130
|
+
resp = self.request("POST", path, data=data, params=params or None)
|
|
131
|
+
if resp.status_code not in (200, 201):
|
|
132
|
+
raise ApiError(f"POST {path} failed ({resp.status_code}): {resp.text[:300]}",
|
|
133
|
+
resp.status_code, resp.text)
|
|
134
|
+
return resp.json() if resp.content else {}
|
|
135
|
+
|
|
136
|
+
# ---- competitions -----------------------------------------------------
|
|
137
|
+
def list_competitions(self, search: "str | None" = None, timeout: int = 180) -> list:
|
|
138
|
+
"""Competitions visible to this account, ordered by id.
|
|
139
|
+
|
|
140
|
+
The unfiltered listing is large and slow to build server-side, hence
|
|
141
|
+
the generous default timeout; pass ``search`` to narrow it down.
|
|
142
|
+
"""
|
|
143
|
+
data = self.get("/api/competitions/", timeout=timeout,
|
|
144
|
+
**({"search": search} if search else {}))
|
|
145
|
+
items = data.get("results", data) if isinstance(data, dict) else data
|
|
146
|
+
return sorted(items, key=lambda c: c.get("id", 0))
|
|
147
|
+
|
|
148
|
+
def competition(self, link: "int | str") -> dict:
|
|
149
|
+
"""Full competition detail from a URL or an id.
|
|
150
|
+
|
|
151
|
+
The payload carries phases, tasks, datasets, pages and leaderboards —
|
|
152
|
+
the helpers in :mod:`codabench.competitions` read it without further
|
|
153
|
+
requests.
|
|
154
|
+
"""
|
|
155
|
+
return self.get(f"/api/competitions/{parse_competition_id(str(link))}/")
|
|
156
|
+
|
|
157
|
+
def leaderboard(self, leaderboard_id: int) -> "dict | None":
|
|
158
|
+
"""Leaderboard with its submissions and scores (None if not visible)."""
|
|
159
|
+
return self.try_get(f"/api/leaderboards/{leaderboard_id}/")
|
|
160
|
+
|
|
161
|
+
def resolve_phase(self, link: "int | str", phase_id: "int | None" = None,
|
|
162
|
+
phase_index: "int | None" = None) -> dict:
|
|
163
|
+
"""Pick the phase to work with; see :func:`codabench.competitions.find_phase`."""
|
|
164
|
+
return find_phase(self.competition(link), phase_id, phase_index)
|
|
165
|
+
|
|
166
|
+
def download_dataset(self, key: str, dest_dir: "str | Path",
|
|
167
|
+
filename: "str | None" = None, extract: bool = True) -> "Path | None":
|
|
168
|
+
"""Download one dataset by key, the way the "Files" tab does.
|
|
169
|
+
|
|
170
|
+
``GET /datasets/download/<key>/`` redirects to signed storage. Returns
|
|
171
|
+
the created path, or None when there is no session or Codabench denies
|
|
172
|
+
access — reference data and unpublished programs are organizer-only,
|
|
173
|
+
which is a normal outcome, not an error.
|
|
174
|
+
"""
|
|
175
|
+
session = self.session()
|
|
176
|
+
if session is None:
|
|
177
|
+
return None
|
|
178
|
+
resp = session.get(self.url(f"/datasets/download/{key}/"),
|
|
179
|
+
timeout=max(self.timeout, 300), allow_redirects=True)
|
|
180
|
+
if resp.status_code != 200:
|
|
181
|
+
return None
|
|
182
|
+
return save_bytes(resp.content, dest_dir, filename or f"{key}.zip", extract)
|
|
183
|
+
|
|
184
|
+
def download_url(self, url: str, dest_dir: "str | Path", filename: str,
|
|
185
|
+
extract: bool = True) -> Path:
|
|
186
|
+
"""Download an already-signed artifact URL.
|
|
187
|
+
|
|
188
|
+
Signed storage URLs authorize through query parameters, so the API
|
|
189
|
+
token is deliberately not sent to a foreign host.
|
|
190
|
+
"""
|
|
191
|
+
same_host = urlparse(url).netloc == urlparse(self.base_url).netloc
|
|
192
|
+
resp = requests.get(url, headers=self.headers if same_host else {},
|
|
193
|
+
timeout=max(self.timeout, 300))
|
|
194
|
+
if resp.status_code != 200:
|
|
195
|
+
raise ApiError(f"download failed ({resp.status_code}) for {filename}",
|
|
196
|
+
resp.status_code, resp.text)
|
|
197
|
+
return save_bytes(resp.content, dest_dir, filename, extract)
|
|
198
|
+
|
|
199
|
+
# ---- submissions ------------------------------------------------------
|
|
200
|
+
def list_submissions(self, phase_id: "int | None" = None) -> list:
|
|
201
|
+
"""Your submissions (optionally on one phase), newest first."""
|
|
202
|
+
data = self.get("/api/submissions/", **({"phase": phase_id} if phase_id else {}))
|
|
203
|
+
items = data.get("results", data) if isinstance(data, dict) else data
|
|
204
|
+
return sorted(items, key=lambda s: s.get("created_when", ""), reverse=True)
|
|
205
|
+
|
|
206
|
+
def pick_submission(self, phase_id: int, last: int = 1) -> dict:
|
|
207
|
+
"""The ``last``-th most recent submission on a phase (1 = latest)."""
|
|
208
|
+
submissions = self.list_submissions(phase_id)
|
|
209
|
+
if not submissions:
|
|
210
|
+
raise CodabenchError(f"no submissions on phase {phase_id} for this account.")
|
|
211
|
+
if not 1 <= last <= len(submissions):
|
|
212
|
+
raise CodabenchError(f"--last {last} is out of range: "
|
|
213
|
+
f"only {len(submissions)} submission(s) on phase {phase_id}.")
|
|
214
|
+
return submissions[last - 1]
|
|
215
|
+
|
|
216
|
+
def submission(self, submission_id: int) -> dict:
|
|
217
|
+
"""One submission (status, scores, phase)."""
|
|
218
|
+
return self.get(f"/api/submissions/{submission_id}/")
|
|
219
|
+
|
|
220
|
+
def submission_details(self, submission_id: int) -> dict:
|
|
221
|
+
"""Submission detail: signed artifact URLs, logs, leaderboard columns."""
|
|
222
|
+
return self.get(f"/api/submissions/{submission_id}/get_details/")
|
|
223
|
+
|
|
224
|
+
def can_submit(self, phase_id: int) -> None:
|
|
225
|
+
"""Raise :class:`CodabenchError` if the phase will not accept a submission."""
|
|
226
|
+
data = self.get(f"/api/can_make_submission/{phase_id}/")
|
|
227
|
+
if not data.get("can", False):
|
|
228
|
+
raise CodabenchError(f"cannot submit to phase {phase_id}: "
|
|
229
|
+
f"{data.get('reason', 'unknown reason')}")
|
|
230
|
+
|
|
231
|
+
def upload_dataset(self, path: "str | Path", dataset_type: str = "submission",
|
|
232
|
+
progress=print) -> str:
|
|
233
|
+
"""Upload a zip and return its dataset key.
|
|
234
|
+
|
|
235
|
+
Two steps, as the web UI does them: create the dataset record to get a
|
|
236
|
+
pre-signed ("sassy") URL, then PUT the bytes straight to storage.
|
|
237
|
+
"""
|
|
238
|
+
self.credentials.require()
|
|
239
|
+
path = Path(path)
|
|
240
|
+
if not path.is_file():
|
|
241
|
+
raise CodabenchError(f"file not found: {path}")
|
|
242
|
+
size = path.stat().st_size
|
|
243
|
+
|
|
244
|
+
dataset = self.post("/api/datasets/", {
|
|
245
|
+
"type": dataset_type,
|
|
246
|
+
"request_sassy_file_name": path.name,
|
|
247
|
+
"file_size": size,
|
|
248
|
+
})
|
|
249
|
+
# Some deployments hand back a docker-internal host; normalise it.
|
|
250
|
+
sassy_url = dataset["sassy_url"].replace("docker.for.mac.localhost", "localhost")
|
|
251
|
+
|
|
252
|
+
if progress:
|
|
253
|
+
progress(f"[..] uploading {path.name} ({size:,} bytes) ...")
|
|
254
|
+
with open(path, "rb") as fh:
|
|
255
|
+
put = requests.put(sassy_url, data=fh, headers={"Content-Type": "application/zip"},
|
|
256
|
+
timeout=max(self.timeout, 600))
|
|
257
|
+
if put.status_code != 200:
|
|
258
|
+
raise ApiError(f"upload failed ({put.status_code}): {put.text[:300]}",
|
|
259
|
+
put.status_code, put.text)
|
|
260
|
+
return dataset["key"]
|
|
261
|
+
|
|
262
|
+
def submit(self, phase_id: int, zip_path: "str | Path", tasks: "list | None" = None,
|
|
263
|
+
progress=print) -> dict:
|
|
264
|
+
"""Upload ``zip_path`` and attach it to ``phase_id``. Returns the submission.
|
|
265
|
+
|
|
266
|
+
This consumes submission quota — check :meth:`can_submit` first.
|
|
267
|
+
"""
|
|
268
|
+
data_key = self.upload_dataset(zip_path, "submission", progress)
|
|
269
|
+
return self.post("/api/submissions/", {
|
|
270
|
+
"phase": phase_id,
|
|
271
|
+
"tasks": tasks or [],
|
|
272
|
+
"data": data_key,
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
def wait_for_submission(self, submission_id: int, interval: int = 15,
|
|
276
|
+
timeout: int = 1800, progress=print) -> dict:
|
|
277
|
+
"""Poll until the submission reaches a terminal state.
|
|
278
|
+
|
|
279
|
+
Returns the submission even when it failed, so the caller can still
|
|
280
|
+
fetch logs and outputs.
|
|
281
|
+
"""
|
|
282
|
+
deadline = time.time() + timeout
|
|
283
|
+
last_status = None
|
|
284
|
+
while time.time() < deadline:
|
|
285
|
+
submission = self.submission(submission_id)
|
|
286
|
+
status = submission.get("status")
|
|
287
|
+
if status != last_status:
|
|
288
|
+
if progress:
|
|
289
|
+
progress(f" status: {status}")
|
|
290
|
+
last_status = status
|
|
291
|
+
if status in TERMINAL_STATES:
|
|
292
|
+
return submission
|
|
293
|
+
time.sleep(interval)
|
|
294
|
+
raise CodabenchError(f"timed out after {timeout}s waiting for submission {submission_id}")
|
|
295
|
+
|
|
296
|
+
def results(self, submission_id: int, submission: "dict | None" = None) -> dict:
|
|
297
|
+
"""Named metric scores plus artifact URLs for a submission.
|
|
298
|
+
|
|
299
|
+
Joins the numeric scores (keyed by ``column_key``) with the leaderboard
|
|
300
|
+
column titles, so metrics come back human-readable. Falls back to
|
|
301
|
+
parsing the scoring output archive when the API returns no inline scores.
|
|
302
|
+
"""
|
|
303
|
+
submission = submission or self.submission(submission_id)
|
|
304
|
+
details = self.try_get(f"/api/submissions/{submission_id}/get_details/") or {}
|
|
305
|
+
|
|
306
|
+
titles = {}
|
|
307
|
+
for board in details.get("leaderboards") or []:
|
|
308
|
+
for column in board.get("columns", []):
|
|
309
|
+
titles[column.get("key")] = column.get("title", column.get("key"))
|
|
310
|
+
|
|
311
|
+
metrics, primary = {}, None
|
|
312
|
+
for score in submission.get("scores") or []:
|
|
313
|
+
name = titles.get(score.get("column_key"), score.get("column_key"))
|
|
314
|
+
try:
|
|
315
|
+
value = float(score.get("score"))
|
|
316
|
+
except (TypeError, ValueError):
|
|
317
|
+
value = score.get("score")
|
|
318
|
+
metrics[name] = value
|
|
319
|
+
if score.get("is_primary"):
|
|
320
|
+
primary = {"metric": name, "score": value}
|
|
321
|
+
|
|
322
|
+
if not metrics and details.get("scoring_result"):
|
|
323
|
+
try:
|
|
324
|
+
resp = requests.get(details["scoring_result"], timeout=self.timeout)
|
|
325
|
+
if resp.status_code == 200:
|
|
326
|
+
metrics = read_scores(resp.content)
|
|
327
|
+
except requests.RequestException:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
"submission_id": submission_id,
|
|
332
|
+
"status": submission.get("status"),
|
|
333
|
+
"phase": submission.get("phase"),
|
|
334
|
+
"phase_name": submission.get("phase_name"),
|
|
335
|
+
"created_when": submission.get("created_when"),
|
|
336
|
+
"primary": primary,
|
|
337
|
+
"metrics": metrics,
|
|
338
|
+
"artifacts": {name: details.get(key) for name, key in ARTIFACTS.items()},
|
|
339
|
+
"logs": {log.get("name"): log.get("data_file") for log in details.get("logs") or []},
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
def download_artifact(self, submission_id: int, artifact: str, dest_dir: "str | Path",
|
|
343
|
+
details: "dict | None" = None, extract: bool = True) -> "Path | None":
|
|
344
|
+
"""Download one submission artifact; see :data:`ARTIFACTS` for the names.
|
|
345
|
+
|
|
346
|
+
Returns None when the submission has no such artifact (e.g. scoring
|
|
347
|
+
never ran).
|
|
348
|
+
"""
|
|
349
|
+
if artifact not in ARTIFACTS:
|
|
350
|
+
raise CodabenchError(f"unknown artifact {artifact!r}; "
|
|
351
|
+
f"choose from {', '.join(ARTIFACTS)}")
|
|
352
|
+
details = details if details is not None else self.submission_details(submission_id)
|
|
353
|
+
url = details.get(ARTIFACTS[artifact])
|
|
354
|
+
if not url:
|
|
355
|
+
return None
|
|
356
|
+
return self.download_url(url, dest_dir, f"{artifact}.zip", extract)
|
|
357
|
+
|
|
358
|
+
def rerun_submission(self, submission_id: int, task_key: str) -> dict:
|
|
359
|
+
"""Re-run an existing submission on another task (robot users only).
|
|
360
|
+
|
|
361
|
+
The submission data is reused; only the task changes.
|
|
362
|
+
"""
|
|
363
|
+
self.credentials.require()
|
|
364
|
+
try:
|
|
365
|
+
return self.post(f"/api/submissions/{submission_id}/re_run_submission/",
|
|
366
|
+
task_key=task_key)
|
|
367
|
+
except ApiError as exc:
|
|
368
|
+
raise ApiError(f"{exc}\nIs this account marked as a robot (is_bot) user?",
|
|
369
|
+
exc.status, exc.body) from exc
|
|
370
|
+
|
|
371
|
+
def list_tasks(self) -> list:
|
|
372
|
+
"""Tasks visible to this account (their ``key`` feeds :meth:`rerun_submission`)."""
|
|
373
|
+
data = self.get("/api/tasks/")
|
|
374
|
+
return data.get("results", data) if isinstance(data, dict) else data
|
|
375
|
+
|
|
376
|
+
# ---- organizer --------------------------------------------------------
|
|
377
|
+
def create_competition(self, bundle_path: "str | Path", interval: int = 5,
|
|
378
|
+
timeout: int = 900, wait: bool = True,
|
|
379
|
+
progress=print) -> dict:
|
|
380
|
+
"""Create a competition from a bundle zip, as the upload page does.
|
|
381
|
+
|
|
382
|
+
Uploads the bundle, tells the server it is complete (which starts
|
|
383
|
+
unpacking) and — unless ``wait`` is False — polls until the new
|
|
384
|
+
competition exists. Returns ``{"status_id", "competition_id"}``.
|
|
385
|
+
"""
|
|
386
|
+
self.credentials.require()
|
|
387
|
+
key = self.upload_dataset(bundle_path, "competition_bundle", progress)
|
|
388
|
+
|
|
389
|
+
resp = requests.put(self.url(f"/api/datasets/completed/{key}/"),
|
|
390
|
+
headers=self.headers, timeout=self.timeout)
|
|
391
|
+
if resp.status_code not in (200, 201):
|
|
392
|
+
raise ApiError(f"failed to finalize upload ({resp.status_code}): {resp.text[:300]}",
|
|
393
|
+
resp.status_code, resp.text)
|
|
394
|
+
status_id = resp.json().get("status_id")
|
|
395
|
+
if status_id is None:
|
|
396
|
+
raise CodabenchError(f"server did not start competition creation: {resp.text[:300]}")
|
|
397
|
+
if progress:
|
|
398
|
+
progress(f"[ok] unpacking started (status_id={status_id}).")
|
|
399
|
+
if not wait:
|
|
400
|
+
return {"status_id": status_id, "competition_id": None}
|
|
401
|
+
|
|
402
|
+
deadline = time.time() + timeout
|
|
403
|
+
last_status = None
|
|
404
|
+
while time.time() < deadline:
|
|
405
|
+
data = self.get(f"/api/competitions/{status_id}/creation_status/")
|
|
406
|
+
status = data.get("status")
|
|
407
|
+
if status != last_status:
|
|
408
|
+
if progress:
|
|
409
|
+
progress(f" status: {status}")
|
|
410
|
+
last_status = status
|
|
411
|
+
if status == "Finished":
|
|
412
|
+
return {"status_id": status_id,
|
|
413
|
+
"competition_id": data.get("resulting_competition")}
|
|
414
|
+
if status == "Failed":
|
|
415
|
+
raise CodabenchError(f"competition creation failed: "
|
|
416
|
+
f"{data.get('details', 'no details')}")
|
|
417
|
+
time.sleep(interval)
|
|
418
|
+
raise CodabenchError(f"timed out after {timeout}s waiting for competition creation.")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def default_env_path() -> str:
|
|
422
|
+
"""``.env`` next to the repository root, the conventional place for credentials."""
|
|
423
|
+
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Pure helpers that read a competition payload.
|
|
2
|
+
|
|
3
|
+
Everything here works on the JSON returned by ``GET /api/competitions/{id}/``
|
|
4
|
+
— no network — so it is easy to test and to reuse in your own tooling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .errors import CodabenchError
|
|
14
|
+
from .text import safe_name, slug, strip_html
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_competition_id(link: str) -> int:
|
|
18
|
+
"""Extract the numeric id from a competition URL or a bare id string.
|
|
19
|
+
|
|
20
|
+
>>> parse_competition_id("https://www.codabench.org/competitions/17363/")
|
|
21
|
+
17363
|
|
22
|
+
>>> parse_competition_id("17363")
|
|
23
|
+
17363
|
|
24
|
+
"""
|
|
25
|
+
link = (link or "").strip()
|
|
26
|
+
if link.isdigit():
|
|
27
|
+
return int(link)
|
|
28
|
+
match = re.search(r"/competitions/(\d+)", link)
|
|
29
|
+
if match:
|
|
30
|
+
return int(match.group(1))
|
|
31
|
+
raise CodabenchError(f"could not find a competition id in {link!r}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CompetitionFile:
|
|
36
|
+
"""One row of a competition's "Files" tab.
|
|
37
|
+
|
|
38
|
+
``key`` is the dataset UUID used by
|
|
39
|
+
:meth:`codabench.client.CodabenchClient.download_dataset`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
key: str
|
|
43
|
+
name: str
|
|
44
|
+
type: str
|
|
45
|
+
phase: str = ""
|
|
46
|
+
task: str = ""
|
|
47
|
+
size: "float | None" = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def filename(self) -> str:
|
|
51
|
+
"""Filesystem-safe ``.zip`` name to save this file under."""
|
|
52
|
+
name = safe_name(self.name, fallback=self.key)
|
|
53
|
+
return name if name.lower().endswith(".zip") else name + ".zip"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def location(self) -> str:
|
|
57
|
+
"""``phase / task`` label, for display."""
|
|
58
|
+
return self.phase + (f" / {self.task}" if self.task else "")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def phases(competition: dict) -> list:
|
|
62
|
+
"""Phases ordered by their index."""
|
|
63
|
+
return sorted(competition.get("phases") or [], key=lambda p: p.get("index", 0))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_phase(competition: dict, phase_id: "int | None" = None,
|
|
67
|
+
phase_index: "int | None" = None) -> dict:
|
|
68
|
+
"""Pick a phase: by id, by index, else the only/currently-open one.
|
|
69
|
+
|
|
70
|
+
Raises :class:`CodabenchError` listing the candidates when the choice is
|
|
71
|
+
ambiguous, so the caller can tell the user what to pass.
|
|
72
|
+
"""
|
|
73
|
+
available = phases(competition)
|
|
74
|
+
if not available:
|
|
75
|
+
raise CodabenchError(f"competition {competition.get('id')} has no phases.")
|
|
76
|
+
if phase_id is not None:
|
|
77
|
+
for phase in available:
|
|
78
|
+
if phase.get("id") == phase_id:
|
|
79
|
+
return phase
|
|
80
|
+
raise CodabenchError(f"no phase with id {phase_id} in this competition.")
|
|
81
|
+
if phase_index is not None:
|
|
82
|
+
for phase in available:
|
|
83
|
+
if phase.get("index") == phase_index:
|
|
84
|
+
return phase
|
|
85
|
+
raise CodabenchError(f"no phase with index {phase_index} in this competition.")
|
|
86
|
+
if len(available) == 1:
|
|
87
|
+
return available[0]
|
|
88
|
+
open_phases = [p for p in available
|
|
89
|
+
if str(p.get("status", "")).lower() in ("current", "active", "open")]
|
|
90
|
+
if len(open_phases) == 1:
|
|
91
|
+
return open_phases[0]
|
|
92
|
+
listing = "\n".join(f" index={p.get('index')} id={p.get('id')} "
|
|
93
|
+
f"status={p.get('status')!r} name={p.get('name')!r}"
|
|
94
|
+
for p in available)
|
|
95
|
+
raise CodabenchError("several phases are open; choose one with --phase:\n" + listing)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def collect_files(competition: dict) -> list:
|
|
99
|
+
"""Every file the competition's "Files" tab lists, as :class:`CompetitionFile`.
|
|
100
|
+
|
|
101
|
+
Mirrors the web UI: phase-level ``public_data`` / ``starting_kit`` plus
|
|
102
|
+
per-task ``public_datasets`` (input & reference data, ingestion & scoring
|
|
103
|
+
programs) and ``solutions``. Whether you may actually *download* each one
|
|
104
|
+
depends on your role and the organizer's settings.
|
|
105
|
+
"""
|
|
106
|
+
files, seen = [], set()
|
|
107
|
+
|
|
108
|
+
def add(key, name, ftype, phase, task="", size=None):
|
|
109
|
+
if key and key not in seen:
|
|
110
|
+
seen.add(key)
|
|
111
|
+
files.append(CompetitionFile(key=key, name=name or ftype, type=ftype,
|
|
112
|
+
phase=phase, task=task, size=size))
|
|
113
|
+
|
|
114
|
+
for phase in phases(competition):
|
|
115
|
+
phase_name = phase.get("name", "")
|
|
116
|
+
for attr in ("public_data", "starting_kit"):
|
|
117
|
+
dataset = phase.get(attr)
|
|
118
|
+
if dataset:
|
|
119
|
+
add(dataset.get("key"), dataset.get("name"), attr, phase_name,
|
|
120
|
+
size=dataset.get("file_size"))
|
|
121
|
+
for task in phase.get("tasks", []):
|
|
122
|
+
task_name = task.get("name", "")
|
|
123
|
+
for dataset in task.get("public_datasets") or []:
|
|
124
|
+
add(dataset.get("key"), dataset.get("name"), dataset.get("type", "dataset"),
|
|
125
|
+
phase_name, task_name, dataset.get("file_size"))
|
|
126
|
+
for solution in task.get("solutions") or []:
|
|
127
|
+
# a solution keeps its dataset key in `data`
|
|
128
|
+
add(solution.get("data"), solution.get("name"), "solution",
|
|
129
|
+
phase_name, task_name, solution.get("size"))
|
|
130
|
+
return files
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def pages(competition: dict) -> list:
|
|
134
|
+
"""Prose pages (Overview, Data, Evaluation, ...) ordered by index."""
|
|
135
|
+
return sorted(competition.get("pages") or [], key=lambda p: p.get("index", 0))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def description_markdown(competition: dict) -> str:
|
|
139
|
+
"""The whole competition description as one markdown document."""
|
|
140
|
+
parts = [f"# {competition.get('title', '?')}", ""]
|
|
141
|
+
for page in pages(competition):
|
|
142
|
+
title = page.get("title") or ""
|
|
143
|
+
body = strip_html(page.get("content") or page.get("data") or "")
|
|
144
|
+
if title:
|
|
145
|
+
parts.append(f"## {title}")
|
|
146
|
+
if body:
|
|
147
|
+
parts += [body, ""]
|
|
148
|
+
if len(parts) <= 2 and competition.get("description"):
|
|
149
|
+
parts.append(strip_html(competition["description"]))
|
|
150
|
+
return "\n".join(parts).strip() + "\n"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def save_description(competition: dict, dest_dir: "str | Path") -> list:
|
|
154
|
+
"""Write ``description.md`` and ``pages/NN-title.md``; return written paths.
|
|
155
|
+
|
|
156
|
+
Page bodies are kept as authored (markdown) rather than stripped, so the
|
|
157
|
+
files stay faithful to what the organizer wrote.
|
|
158
|
+
"""
|
|
159
|
+
dest = Path(dest_dir)
|
|
160
|
+
pages_dir = dest / "pages"
|
|
161
|
+
pages_dir.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
|
|
163
|
+
written = []
|
|
164
|
+
for i, page in enumerate(pages(competition)):
|
|
165
|
+
title = page.get("title") or f"page-{i}"
|
|
166
|
+
body = page.get("content") or page.get("data") or ""
|
|
167
|
+
try:
|
|
168
|
+
index = int(page.get("index", i))
|
|
169
|
+
except (TypeError, ValueError):
|
|
170
|
+
index = i
|
|
171
|
+
path = pages_dir / f"{index:02d}-{slug(title)}.md"
|
|
172
|
+
header = "" if body.lstrip().startswith("#") else f"# {title}\n\n"
|
|
173
|
+
path.write_text(header + body, encoding="utf-8")
|
|
174
|
+
written.append(path)
|
|
175
|
+
|
|
176
|
+
description = dest / "description.md"
|
|
177
|
+
description.write_text(description_markdown(competition), encoding="utf-8")
|
|
178
|
+
written.append(description)
|
|
179
|
+
return written
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def primary_metric(competition: dict) -> dict:
|
|
183
|
+
"""The leaderboard's primary column: ``{leaderboard, key, title, higher_is_better}``.
|
|
184
|
+
|
|
185
|
+
Empty dict when the competition exposes no leaderboard.
|
|
186
|
+
"""
|
|
187
|
+
leaderboards = competition.get("leaderboards") or []
|
|
188
|
+
if not leaderboards:
|
|
189
|
+
return {}
|
|
190
|
+
board = leaderboards[0]
|
|
191
|
+
columns = board.get("columns") or []
|
|
192
|
+
index = board.get("primary_index") or 0
|
|
193
|
+
column = columns[index] if index < len(columns) else (columns[0] if columns else {})
|
|
194
|
+
return {
|
|
195
|
+
"leaderboard_id": board.get("id"),
|
|
196
|
+
"leaderboard": board.get("title"),
|
|
197
|
+
"key": column.get("key"),
|
|
198
|
+
"title": column.get("title"),
|
|
199
|
+
"higher_is_better": column.get("sorting") == "desc",
|
|
200
|
+
}
|