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/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
+ }