tmp-nepher-cli 0.2.5__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.
@@ -0,0 +1,656 @@
1
+ """tournament command group — list, status, leaderboard, validate, submit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import tempfile
9
+ import time
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import click
15
+ import httpx
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.table import Table
19
+
20
+ from nepher_cli.commands.highlight import highlight as highlight_group
21
+ from nepher_cli.config import TOURNAMENT_BACKEND
22
+ from nepher_cli.core.credentials import get_auth_headers, get_stored_api_key
23
+ from nepher_cli.core.http import parse_error_body
24
+ from nepher_cli.tournament.agent_check import check_agent_structure
25
+ from nepher_cli.tournament import api as tournament_api
26
+ from nepher_cli.tournament.packer import compute_checksum, get_file_size, zip_directory
27
+ from nepher_cli.tournament.wallet import prepare_submission_credentials
28
+
29
+ console = Console(stderr=True)
30
+
31
+
32
+ def _resolve_api_key(api_key: str | None) -> str:
33
+ resolved = api_key or os.environ.get("NEPHER_API_KEY") or get_stored_api_key()
34
+ if not resolved:
35
+ console.print(
36
+ "[red]No API key available.[/red] "
37
+ "Pass [bold]--api-key[/bold] or set [bold]NEPHER_API_KEY[/bold] "
38
+ "or run [bold]npcli account login[/bold] first."
39
+ )
40
+ raise SystemExit(1)
41
+ return resolved
42
+
43
+
44
+ def _base() -> str:
45
+ return TOURNAMENT_BACKEND.rstrip("/")
46
+
47
+
48
+ def _optional_headers() -> dict[str, str]:
49
+ """Use stored credentials when logged in; public endpoints work without auth."""
50
+ h = get_auth_headers(None)
51
+ if not h:
52
+ ak = get_stored_api_key()
53
+ if ak:
54
+ h = {"X-API-Key": ak}
55
+ return h
56
+
57
+
58
+ def _tournament_timestamp(t: dict[str, Any], key: str) -> int | None:
59
+ val = t.get(key)
60
+ return int(val) if val is not None else None
61
+
62
+
63
+ def _is_submittable(t: dict[str, Any], now: int | None = None) -> bool:
64
+ """True when contest_start_time <= now < contest_end_time."""
65
+ if now is None:
66
+ now = int(time.time())
67
+ start = _tournament_timestamp(t, "contest_start_time")
68
+ end = _tournament_timestamp(t, "contest_end_time")
69
+ if start is None or end is None:
70
+ return False
71
+ return start <= now < end
72
+
73
+
74
+ def _describe_stage(t: dict[str, Any], now: int | None = None) -> str:
75
+ """Human-readable current stage for a tournament."""
76
+ if now is None:
77
+ now = int(time.time())
78
+ cs = _tournament_timestamp(t, "contest_start_time")
79
+ ce = _tournament_timestamp(t, "contest_end_time")
80
+ es = _tournament_timestamp(t, "evaluation_start_time")
81
+ ee = _tournament_timestamp(t, "evaluation_end_time")
82
+ rs = _tournament_timestamp(t, "reward_start_time")
83
+ re_ = _tournament_timestamp(t, "reward_end_time")
84
+ sw = _tournament_timestamp(t, "submit_window_start_time")
85
+ if cs is None or now < cs:
86
+ return "upcoming"
87
+ if re_ is not None and now >= re_:
88
+ return "completed"
89
+ if rs is not None and now >= rs:
90
+ return "reward"
91
+ if ee is not None and now >= ee:
92
+ return "review"
93
+ if es is not None and now >= es:
94
+ return "evaluation"
95
+ if ce is not None and now >= ce:
96
+ return "evaluation"
97
+ if sw is not None and now >= sw:
98
+ return "submit"
99
+ return "contest"
100
+
101
+
102
+ def _tournament_title(t: dict[str, Any]) -> str:
103
+ return t.get("title") or t.get("subtitle") or t.get("name") or "—"
104
+
105
+
106
+ def _tournament_task_name(t: dict[str, Any]) -> str:
107
+ return t.get("task_name") or "—"
108
+
109
+
110
+ def _tournament_versions(t: dict[str, Any]) -> str:
111
+ task_v = t.get("task_version")
112
+ tourn_v = t.get("tournament_version")
113
+ if task_v is not None and tourn_v is not None:
114
+ return f"{task_v}.{tourn_v}"
115
+ if task_v is not None:
116
+ return str(task_v)
117
+ if tourn_v is not None:
118
+ return str(tourn_v)
119
+ return "—"
120
+
121
+
122
+ _EMPTY = "-"
123
+
124
+
125
+ def _fmt_unix(ts: int | None) -> str:
126
+ if ts is None:
127
+ return _EMPTY
128
+ return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
129
+
130
+
131
+ def _fmt_time_range(start: int | None, end: int | None) -> str:
132
+ if start is None and end is None:
133
+ return _EMPTY
134
+ if start is not None and end is not None:
135
+ return f"{_fmt_unix(start)} to {_fmt_unix(end)}"
136
+ return _fmt_unix(start if start is not None else end)
137
+
138
+
139
+ def _fmt_block_range(start: int | None, end: int | None) -> str:
140
+ if start is None and end is None:
141
+ return _EMPTY
142
+ if start is not None and end is not None:
143
+ return f"{start:,} - {end:,}"
144
+ return f"{start or end:,}"
145
+
146
+
147
+ def _status_markup(status: str | None) -> str:
148
+ if not status:
149
+ return _EMPTY
150
+ colors = {"active": "green", "approved": "blue", "cancelled": "red", "completed": "dim"}
151
+ color = colors.get(status.lower(), "white")
152
+ return f"[{color}]{status}[/{color}]"
153
+
154
+
155
+ def _subnet_field(data: dict[str, Any], key: str) -> Any:
156
+ cfg = data.get("subnet_config") or {}
157
+ return data.get(key) if data.get(key) is not None else cfg.get(key)
158
+
159
+
160
+ def _render_tournament_status(data: dict[str, Any]) -> None:
161
+ from rich import print as rprint
162
+
163
+ title = _tournament_title(data)
164
+ tid = str(data.get("id", "?"))
165
+ header_lines = [f"[bold]{title}[/bold]", f"[cyan]{tid}[/cyan]"]
166
+ if data.get("subtitle"):
167
+ header_lines.append(f"[dim]{data['subtitle']}[/dim]")
168
+ rprint(Panel("\n".join(header_lines), border_style="cyan", padding=(0, 1)))
169
+
170
+ overview = Table(show_header=False, box=None, padding=(0, 2))
171
+ overview.add_column(style="dim", no_wrap=True)
172
+ overview.add_column()
173
+ overview.add_row("Status", _status_markup(data.get("status")))
174
+ overview.add_row("Stage", str(data.get("stage") or _EMPTY))
175
+ if data.get("is_active") is not None:
176
+ active = "[green]yes[/green]" if data.get("is_active") else "[dim]no[/dim]"
177
+ overview.add_row("Active", active)
178
+ overview.add_row("Task", _tournament_task_name(data))
179
+ overview.add_row("Versions", _tournament_versions(data))
180
+ if data.get("difficulty"):
181
+ overview.add_row("Difficulty", str(data["difficulty"]))
182
+ if data.get("tags"):
183
+ overview.add_row("Tags", ", ".join(data["tags"]))
184
+ if data.get("is_featured"):
185
+ overview.add_row("Featured", "[yellow]yes[/yellow]")
186
+ network = _subnet_field(data, "network")
187
+ subnet_uid = _subnet_field(data, "subnet_uid")
188
+ if network is not None:
189
+ overview.add_row("Network", str(network))
190
+ if subnet_uid is not None:
191
+ overview.add_row("Subnet UID", str(subnet_uid))
192
+ if data.get("is_test"):
193
+ overview.add_row("Test mode", "[yellow]yes[/yellow]")
194
+ if data.get("has_public_eval"):
195
+ phase = data.get("current_eval_phase") or _EMPTY
196
+ overview.add_row("Eval phase", str(phase))
197
+ rprint(overview)
198
+
199
+ phases = Table(title="Schedule", show_header=True, header_style="bold", box=None, padding=(0, 1))
200
+ phases.add_column("Phase", style="dim", no_wrap=True)
201
+ phases.add_column("Time (UTC)", no_wrap=True)
202
+ phases.add_column("Blocks", justify="right", no_wrap=True)
203
+
204
+ phase_rows: list[tuple[str, int | None, int | None, int | None, int | None]] = [
205
+ ("Contest", data.get("contest_start_time"), data.get("contest_end_time"),
206
+ data.get("contest_start_block"), data.get("contest_end_block")),
207
+ ("Submit window", data.get("submit_window_start_time"), data.get("contest_end_time"),
208
+ data.get("submit_window_start_block"), data.get("contest_end_block")),
209
+ ("Evaluation", data.get("evaluation_start_time"), data.get("evaluation_end_time"),
210
+ data.get("evaluation_start_block"), data.get("evaluation_end_block")),
211
+ ("Reward", data.get("reward_start_time"), data.get("reward_end_time"),
212
+ data.get("reward_start_block"), data.get("reward_end_block")),
213
+ ]
214
+ if data.get("has_public_eval") and data.get("public_eval_end_time"):
215
+ phase_rows.insert(2, (
216
+ "Public eval ends",
217
+ None,
218
+ data.get("public_eval_end_time"),
219
+ None,
220
+ None,
221
+ ))
222
+
223
+ for label, t_start, t_end, b_start, b_end in phase_rows:
224
+ if t_start is None and t_end is None and b_start is None and b_end is None:
225
+ continue
226
+ time_col = _fmt_unix(t_end) if label == "Public eval ends" else _fmt_time_range(t_start, t_end)
227
+ phases.add_row(label, time_col, _fmt_block_range(b_start, b_end))
228
+
229
+ rprint(phases)
230
+
231
+ stats = data.get("statistics") or {}
232
+ if stats:
233
+ stats_table = Table(title="Statistics", show_header=False, box=None, padding=(0, 2))
234
+ stats_table.add_column(style="dim", no_wrap=True)
235
+ stats_table.add_column(justify="right")
236
+ for label, key in [
237
+ ("Agents submitted", "agents_count"),
238
+ ("Participants", "participants_count"),
239
+ ("Eligible miners", "eligible_count"),
240
+ ("Validators", "validator_count"),
241
+ ]:
242
+ val = stats.get(key)
243
+ if val is not None:
244
+ stats_table.add_row(label, str(val))
245
+ if stats.get("top_score") is not None:
246
+ stats_table.add_row("Top score", f"{float(stats['top_score']):.4f}")
247
+ if stats.get("average_score") is not None:
248
+ stats_table.add_row("Average score", f"{float(stats['average_score']):.4f}")
249
+ if stats.get("score_phase"):
250
+ stats_table.add_row("Score phase", str(stats["score_phase"]))
251
+ rprint(stats_table)
252
+
253
+ eval_cfg = data.get("eval_config") or {}
254
+ if eval_cfg:
255
+ eval_table = Table(title="Evaluation", show_header=False, box=None, padding=(0, 2))
256
+ eval_table.add_column(style="dim", no_wrap=True)
257
+ eval_table.add_column()
258
+ if eval_cfg.get("task_name"):
259
+ eval_table.add_row("Eval task", str(eval_cfg["task_name"]))
260
+ if eval_cfg.get("category"):
261
+ eval_table.add_row("Category", str(eval_cfg["category"]))
262
+ scenes = eval_cfg.get("env_scenes") or []
263
+ if scenes:
264
+ scene_parts = [
265
+ f"{s.get('env_id', '?')}" + (f" (scene {s['scene']})" if s.get("scene") is not None else "")
266
+ for s in scenes
267
+ ]
268
+ eval_table.add_row("Environments", ", ".join(scene_parts))
269
+ for label, key in [
270
+ ("Episodes", "num_episodes"),
271
+ ("Max steps", "max_episode_steps"),
272
+ ("Parallel envs", "num_envs"),
273
+ ]:
274
+ if eval_cfg.get(key) is not None:
275
+ eval_table.add_row(label, str(eval_cfg[key]))
276
+ rprint(eval_table)
277
+
278
+ links = Table(title="Repositories", show_header=False, box=None, padding=(0, 2))
279
+ links.add_column(style="dim", no_wrap=True)
280
+ links.add_column()
281
+ has_links = False
282
+ if data.get("task_gh"):
283
+ links.add_row("Task repo", str(data["task_gh"]))
284
+ has_links = True
285
+ if data.get("eval_gh"):
286
+ links.add_row("Eval repo", str(data["eval_gh"]))
287
+ has_links = True
288
+ if has_links:
289
+ rprint(links)
290
+
291
+ if data.get("winner_hotkey"):
292
+ winner = Table(title="Winner", show_header=False, box=None, padding=(0, 2))
293
+ winner.add_column(style="dim", no_wrap=True)
294
+ winner.add_column()
295
+ winner.add_row("Hotkey", str(data["winner_hotkey"]))
296
+ if data.get("winner_score") is not None:
297
+ winner.add_row("Score", f"{float(data['winner_score']):.4f}")
298
+ if data.get("winner_agent_id"):
299
+ winner.add_row("Agent ID", str(data["winner_agent_id"]))
300
+ approved = "[green]yes[/green]" if data.get("winner_approved") else "[dim]pending[/dim]"
301
+ winner.add_row("Approved", approved)
302
+ rprint(winner)
303
+
304
+ meta_parts: list[str] = []
305
+ if data.get("created_at"):
306
+ meta_parts.append(f"Created {data['created_at']}")
307
+ if data.get("updated_at"):
308
+ meta_parts.append(f"Updated {data['updated_at']}")
309
+ if meta_parts:
310
+ rprint(f"[dim]{' | '.join(meta_parts)}[/dim]")
311
+
312
+
313
+ @click.group("tournament")
314
+ def tournament() -> None:
315
+ """Browse tournaments, check leaderboards, validate agents, and submit to Subnet 49."""
316
+
317
+
318
+ tournament.add_command(highlight_group)
319
+
320
+
321
+ @tournament.command("list")
322
+ @click.option("--active-only", is_flag=True, help="Show only active tournaments.")
323
+ @click.option("--limit", type=int, default=50, show_default=True, help="Maximum tournaments to return.")
324
+ @click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
325
+ def tournament_list(active_only: bool, limit: int, output_json: bool) -> None:
326
+ """List tournaments.
327
+
328
+ Public endpoint — no login required. Stored credentials are used automatically
329
+ when present (e.g. to include admin-only tournaments).
330
+ """
331
+ headers = _optional_headers()
332
+ params: dict[str, Any] = {"limit": limit}
333
+ if active_only:
334
+ params["status"] = "active"
335
+ else:
336
+ params["include_active"] = "true"
337
+
338
+ url = f"{_base()}/api/v1/tournaments/list"
339
+ try:
340
+ r = httpx.get(url, headers=headers, params=params, timeout=30.0)
341
+ except httpx.RequestError as e:
342
+ console.print(f"[red]Network error[/red]: {e}")
343
+ raise SystemExit(1) from e
344
+
345
+ if r.status_code != 200:
346
+ console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
347
+ raise SystemExit(1)
348
+
349
+ try:
350
+ data = r.json()
351
+ except Exception:
352
+ console.print("[red]Invalid JSON response.[/red]")
353
+ raise SystemExit(1)
354
+
355
+ if output_json:
356
+ click.echo(json.dumps(data, indent=2))
357
+ return
358
+
359
+ items: list[dict[str, Any]] = (
360
+ data if isinstance(data, list) else data.get("tournaments", data.get("results", []))
361
+ )
362
+
363
+ if not items:
364
+ console.print("[dim]No tournaments found.[/dim]")
365
+ return
366
+
367
+ table = Table(show_header=True, header_style="bold", box=None)
368
+ table.add_column("ID", style="cyan", no_wrap=True)
369
+ table.add_column("Title")
370
+ table.add_column("Task", style="dim", no_wrap=True)
371
+ table.add_column("Versions", justify="right", no_wrap=True)
372
+ table.add_column("Status")
373
+
374
+ for t in items:
375
+ tid = str(t.get("id", ""))
376
+ table.add_row(
377
+ tid,
378
+ _tournament_title(t),
379
+ _tournament_task_name(t),
380
+ _tournament_versions(t),
381
+ t.get("status") or "—",
382
+ )
383
+
384
+ from rich import print as rprint
385
+ rprint(table)
386
+ console.print(f"\n[dim]{len(items)} tournament(s) listed.[/dim]")
387
+
388
+
389
+ @tournament.command("status")
390
+ @click.argument("tournament_id")
391
+ @click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
392
+ def tournament_status(tournament_id: str, output_json: bool) -> None:
393
+ """Show the current status and configuration of a tournament."""
394
+ headers = _optional_headers()
395
+ url = f"{_base()}/api/v1/tournaments/{tournament_id}"
396
+ try:
397
+ r = httpx.get(url, headers=headers, timeout=30.0)
398
+ except httpx.RequestError as e:
399
+ console.print(f"[red]Network error[/red]: {e}")
400
+ raise SystemExit(1) from e
401
+
402
+ if r.status_code != 200:
403
+ console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
404
+ raise SystemExit(1)
405
+
406
+ try:
407
+ data = r.json()
408
+ except Exception:
409
+ console.print("[red]Invalid JSON response.[/red]")
410
+ raise SystemExit(1)
411
+
412
+ if output_json:
413
+ click.echo(json.dumps(data, indent=2))
414
+ return
415
+
416
+ _render_tournament_status(data)
417
+
418
+
419
+ @tournament.command("leaderboard")
420
+ @click.argument("tournament_id")
421
+ @click.option("--limit", type=int, default=20, show_default=True, help="Number of entries to show.")
422
+ @click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
423
+ def tournament_leaderboard(tournament_id: str, limit: int, output_json: bool) -> None:
424
+ """Show the score leaderboard for a tournament."""
425
+ headers = _optional_headers()
426
+ url = f"{_base()}/api/v1/scores/leaderboard/{tournament_id}"
427
+ params: dict[str, Any] = {"limit": limit}
428
+ try:
429
+ r = httpx.get(url, headers=headers, params=params, timeout=30.0)
430
+ except httpx.RequestError as e:
431
+ console.print(f"[red]Network error[/red]: {e}")
432
+ raise SystemExit(1) from e
433
+
434
+ if r.status_code != 200:
435
+ console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
436
+ raise SystemExit(1)
437
+
438
+ try:
439
+ data = r.json()
440
+ except Exception:
441
+ console.print("[red]Invalid JSON response.[/red]")
442
+ raise SystemExit(1)
443
+
444
+ if output_json:
445
+ click.echo(json.dumps(data, indent=2))
446
+ return
447
+
448
+ scores: list[dict[str, Any]] = (
449
+ data if isinstance(data, list)
450
+ else data.get("entries", data.get("scores", data.get("results", [])))
451
+ )
452
+
453
+ if not scores:
454
+ console.print("[dim]No scores found for this tournament.[/dim]")
455
+ return
456
+
457
+ table = Table(show_header=True, header_style="bold", box=None)
458
+ table.add_column("Rank", justify="right")
459
+ table.add_column("Miner Hotkey")
460
+ table.add_column("Score", justify="right")
461
+ table.add_column("Agent ID")
462
+
463
+ for s in scores[:limit]:
464
+ rank = s.get("rank", "—")
465
+ hotkey = s.get("miner_hotkey") or s.get("hotkey") or "—"
466
+ raw_score = s.get("aggregated_score", s.get("score", 0))
467
+ score = str(round(float(raw_score or 0), 4))
468
+ agent_id = str(s.get("agent_id", "—"))
469
+ table.add_row(str(rank), hotkey, score, agent_id)
470
+
471
+ from rich import print as rprint
472
+ rprint(table)
473
+
474
+
475
+ @tournament.command("check")
476
+ @click.option("--path", "agent_path", type=click.Path(), required=True, help="Path to agent directory.")
477
+ @click.option("--verbose", "-v", is_flag=True, help="Show warnings for missing recommended files.")
478
+ def tournament_check(agent_path: str, verbose: bool) -> None:
479
+ """Check local agent directory structure without submitting.
480
+
481
+ Verifies required files (best_policy/best_policy.pt, source/) and warns
482
+ about missing recommended files (scripts/rsl_rl/play.py, etc.).
483
+
484
+ No extra dependencies required — runs entirely offline.
485
+ """
486
+ path = Path(agent_path)
487
+ console.print(f"Validating agent at [bold]{path}[/bold]...")
488
+
489
+ is_valid, errors, warnings = check_agent_structure(path)
490
+
491
+ if warnings and verbose:
492
+ for w in warnings:
493
+ console.print(f" [yellow]warning:[/yellow] {w}")
494
+
495
+ if is_valid:
496
+ console.print("[green]Agent structure is valid.[/green]")
497
+ raise SystemExit(0)
498
+ else:
499
+ console.print("[red]Agent validation failed:[/red]")
500
+ for err in errors:
501
+ console.print(f" • {err}")
502
+ raise SystemExit(1)
503
+
504
+
505
+ @tournament.command("submit")
506
+ @click.option("--path", "agent_path", type=click.Path(), required=True, help="Path to agent directory.")
507
+ @click.option("--wallet-name", default="miner", show_default=True, help="Bittensor wallet name.")
508
+ @click.option("--wallet-hotkey", default="default", show_default=True, help="Bittensor wallet hotkey.")
509
+ @click.option(
510
+ "--api-key", "--apikey", "api_key",
511
+ default=None, envvar="NEPHER_API_KEY", metavar="KEY",
512
+ help="Nepher API key (nepher_...). Identifies your account; falls back to stored credentials.",
513
+ )
514
+ @click.option("--api-url", default=None, help=f"Tournament API URL (default: {TOURNAMENT_BACKEND}).")
515
+ @click.option("--tournament-id", default=None, help="Target tournament ID (required when multiple are active).")
516
+ @click.option("--verbose", "-v", is_flag=True)
517
+ def tournament_submit(
518
+ agent_path: str,
519
+ wallet_name: str,
520
+ wallet_hotkey: str,
521
+ api_key: str | None,
522
+ api_url: str | None,
523
+ tournament_id: str | None,
524
+ verbose: bool,
525
+ ) -> None:
526
+ """Submit a trained agent to Bittensor Subnet 49 tournaments.
527
+
528
+ Your Nepher account is identified by the API key (--api-key, NEPHER_API_KEY,
529
+ or credentials from npcli account login). The wallet hotkey signs the archive.
530
+
531
+ Requires [bold]bittensor-wallet[/bold] for wallet signing:
532
+ pip install bittensor-wallet
533
+ # or: pip install "nepher-cli[bittensor]"
534
+ """
535
+ resolved_key = _resolve_api_key(api_key)
536
+ resolved_url = api_url or TOURNAMENT_BACKEND
537
+ path = Path(agent_path)
538
+
539
+ console.print("Checking agent structure...")
540
+ is_valid, errors, warnings = check_agent_structure(path)
541
+ if warnings and verbose:
542
+ for w in warnings:
543
+ console.print(f" [yellow]warning:[/yellow] {w}")
544
+ if not is_valid:
545
+ console.print("[red]Agent validation failed:[/red]")
546
+ for err in errors:
547
+ console.print(f" • {err}")
548
+ raise SystemExit(1)
549
+ console.print("[green]Agent structure valid.[/green]")
550
+
551
+ async def _run() -> int:
552
+ try:
553
+ with tempfile.TemporaryDirectory() as tmpdir:
554
+ archive = Path(tmpdir) / "agent.zip"
555
+
556
+ console.print("Creating submission archive...")
557
+ zip_directory(path, archive)
558
+ content_hash = compute_checksum(archive)
559
+ file_size = get_file_size(archive)
560
+ if verbose:
561
+ console.print(
562
+ f" [dim]Archive: {file_size:,} bytes, "
563
+ f"sha256: {content_hash[:16]}...[/dim]"
564
+ )
565
+
566
+ console.print("Signing with wallet...")
567
+ miner_hotkey, public_key, file_info, signature = (
568
+ prepare_submission_credentials(wallet_name, wallet_hotkey, content_hash)
569
+ )
570
+
571
+ console.print("Requesting upload token...")
572
+ token_data = await tournament_api.request_upload_token(
573
+ api_key=resolved_key,
574
+ api_url=resolved_url,
575
+ miner_hotkey=miner_hotkey,
576
+ public_key=public_key,
577
+ file_info=file_info,
578
+ signature=signature,
579
+ file_size=file_size,
580
+ tournament_id=tournament_id,
581
+ )
582
+ resolved_tournament_id = token_data["tournament_id"]
583
+ upload_token = token_data["upload_token"]
584
+
585
+ console.print("Uploading agent...")
586
+ agent_id = await tournament_api.upload_agent(
587
+ api_key=resolved_key,
588
+ api_url=resolved_url,
589
+ tournament_id=resolved_tournament_id,
590
+ upload_token=upload_token,
591
+ miner_hotkey=miner_hotkey,
592
+ content_hash=content_hash,
593
+ file_path=archive,
594
+ )
595
+
596
+ t_title = _tournament_title(token_data)
597
+ t_task = _tournament_task_name(token_data)
598
+ t_version = _tournament_versions(token_data)
599
+
600
+ console.print("[green]Agent submitted successfully.[/green]")
601
+ console.print(f" Agent ID : [bold]{agent_id}[/bold]")
602
+ console.print(f" Tournament ID: [bold]{resolved_tournament_id}[/bold]")
603
+ if t_title and t_title != "—":
604
+ console.print(f" Title : {t_title}")
605
+ if t_task and t_task != "—":
606
+ console.print(f" Task : {t_task}")
607
+ if t_version and t_version != "—":
608
+ console.print(f" Version : {t_version}")
609
+ return 0
610
+ except Exception as e:
611
+ console.print(f"[red]Submission failed:[/red] {e}")
612
+ return 1
613
+
614
+ raise SystemExit(asyncio.run(_run()))
615
+
616
+
617
+ @tournament.command("list-active")
618
+ @click.option("--api-url", default=None, help=f"Tournament API URL (default: {TOURNAMENT_BACKEND}).")
619
+ def tournament_list_active(api_url: str | None) -> None:
620
+ """List active tournaments and whether they accept submissions.
621
+
622
+ Public endpoint — no login required.
623
+ """
624
+ base = (api_url or TOURNAMENT_BACKEND).rstrip("/")
625
+ url = f"{base}/api/v1/tournaments/active/list"
626
+ try:
627
+ r = httpx.get(url, headers=_optional_headers(), params={"subnet": "true"}, timeout=30.0)
628
+ except httpx.RequestError as e:
629
+ console.print(f"[red]Network error[/red]: {e}")
630
+ raise SystemExit(1) from e
631
+
632
+ if r.status_code != 200:
633
+ console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
634
+ raise SystemExit(1)
635
+
636
+ try:
637
+ data = r.json()
638
+ except Exception:
639
+ console.print("[red]Invalid JSON response.[/red]")
640
+ raise SystemExit(1)
641
+
642
+ tournaments: list[dict[str, Any]] = (
643
+ data.get("tournaments", []) if isinstance(data, dict) else data
644
+ )
645
+
646
+ if not tournaments:
647
+ console.print("[dim]No active tournaments.[/dim]")
648
+ return
649
+
650
+ console.print(f"[bold]{len(tournaments)} active tournament(s):[/bold]")
651
+ for t in tournaments:
652
+ accepting = "[green]yes[/green]" if _is_submittable(t) else "[red]no[/red]"
653
+ console.print(
654
+ f" {t.get('id', '?')} | {t.get('task_name', '?')} | "
655
+ f"stage={_describe_stage(t)} | accepts_submissions={accepting}"
656
+ )