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