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,760 @@
|
|
|
1
|
+
"""hackathon command group — submit logic and CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
import httpx
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from nepher_cli.config import HACKATHON_BACKEND
|
|
19
|
+
from nepher_cli.core.credentials import get_stored_api_key
|
|
20
|
+
from nepher_cli.core.http import parse_error_body, request_json
|
|
21
|
+
|
|
22
|
+
console = Console(stderr=True)
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Constants
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
|
29
|
+
VIDEO_EXTS = {".mp4", ".webm", ".mov"}
|
|
30
|
+
PDF_EXTS = {".pdf"}
|
|
31
|
+
ALLOWED_ASSET_EXTS = IMAGE_EXTS | VIDEO_EXTS | PDF_EXTS
|
|
32
|
+
|
|
33
|
+
DEFAULT_MAX_SUBMISSION_ZIP_BYTES = 512 * 1024 * 1024 # 512 MiB
|
|
34
|
+
|
|
35
|
+
MAX_FILES_IN_SUBMISSION = 20_000
|
|
36
|
+
MAX_FILES_IN_ASSETS = 5000
|
|
37
|
+
MAX_TOTAL_ASSETS_UNCOMPRESSED = 2 * 1024 * 1024 * 1024 # 2 GiB
|
|
38
|
+
|
|
39
|
+
BLOCKED_SUFFIXES = (
|
|
40
|
+
".exe", ".dll", ".bat", ".cmd", ".com",
|
|
41
|
+
".msi", ".scr", ".pif", ".vbs",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
SECRET_TEXT_SUFFIXES = {".py", ".ts", ".tsx", ".js", ".json", ".env", ".yaml", ".yml", ".toml", ".md"}
|
|
45
|
+
SECRET_PATTERNS = [
|
|
46
|
+
re.compile(rb"AKIA[0-9A-Z]{16}"),
|
|
47
|
+
re.compile(rb"sk_live_[a-zA-Z0-9]{20,}"),
|
|
48
|
+
re.compile(rb"-----BEGIN (RSA |OPENSSH )?PRIVATE KEY-----"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
MAX_SUBMISSION_TITLE_LEN = 200
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Core validation helpers
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_api_key_format(api_key: str) -> None:
|
|
59
|
+
if not api_key.startswith("nepher_"):
|
|
60
|
+
console.print(
|
|
61
|
+
"[red]invalid api key format[/red] — keys must start with [bold]nepher_[/bold]."
|
|
62
|
+
)
|
|
63
|
+
raise SystemExit(1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_submission_metadata(title: str, description: str) -> tuple[str, str]:
|
|
67
|
+
"""Validate title and description; return stripped versions or raise SystemExit."""
|
|
68
|
+
title_stripped = (title or "").strip()
|
|
69
|
+
description_stripped = (description or "").strip()
|
|
70
|
+
if not title_stripped:
|
|
71
|
+
console.print("[red]title is required[/red] — pass [bold]--title[/bold] (max 200 characters).")
|
|
72
|
+
raise SystemExit(1)
|
|
73
|
+
if len(title_stripped) > MAX_SUBMISSION_TITLE_LEN:
|
|
74
|
+
console.print(
|
|
75
|
+
f"[red]title is too long[/red] — {len(title_stripped)} characters "
|
|
76
|
+
f"(max {MAX_SUBMISSION_TITLE_LEN})."
|
|
77
|
+
)
|
|
78
|
+
raise SystemExit(1)
|
|
79
|
+
return title_stripped, description_stripped
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _print_quota_line(prefix: str, body: dict[str, Any]) -> None:
|
|
83
|
+
rem = body.get("submissions_remaining")
|
|
84
|
+
max_n = body.get("max_submissions_per_user")
|
|
85
|
+
used = body.get("submission_attempts_used")
|
|
86
|
+
if isinstance(rem, int) and isinstance(max_n, int) and isinstance(used, int):
|
|
87
|
+
console.print(
|
|
88
|
+
f"{prefix}: [bold]{rem}[/bold] of {max_n} upload attempt(s) remaining ({used} used successfully)."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _preflight_url(base: str) -> str:
|
|
93
|
+
return f"{base.rstrip('/')}/api/v1/hackathon/submit/preflight/"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _upload_url(base: str) -> str:
|
|
97
|
+
return f"{base.rstrip('/')}/api/v1/hackathon/submit/upload/"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_dangerous_path(name: str) -> bool:
|
|
101
|
+
n = name.replace("\\", "/").strip()
|
|
102
|
+
if not n or n.endswith("/"):
|
|
103
|
+
return False
|
|
104
|
+
for p in n.split("/"):
|
|
105
|
+
if p in ("..", "") or p.startswith(".."):
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _suffix(name: str) -> str:
|
|
111
|
+
return Path(name).suffix.lower()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _require_existing_path(path: Path, label: str) -> None:
|
|
115
|
+
if not path.exists():
|
|
116
|
+
console.print(f"[red]Path not found[/red]: {path}")
|
|
117
|
+
raise SystemExit(1)
|
|
118
|
+
if not path.is_dir() and not path.is_file():
|
|
119
|
+
console.print(f"[red]{label} must be a directory or zip file[/red]: {path}")
|
|
120
|
+
raise SystemExit(1)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _iter_directory_files(root: Path) -> list[tuple[str, Path]]:
|
|
124
|
+
"""Return (archive member name, absolute file path) for all regular files under root."""
|
|
125
|
+
root = root.resolve()
|
|
126
|
+
out: list[tuple[str, Path]] = []
|
|
127
|
+
for dirpath, _dirnames, filenames in os.walk(root):
|
|
128
|
+
base = Path(dirpath)
|
|
129
|
+
for name in filenames:
|
|
130
|
+
full = base / name
|
|
131
|
+
if full.is_symlink() or not full.is_file():
|
|
132
|
+
continue
|
|
133
|
+
rel = full.relative_to(root).as_posix()
|
|
134
|
+
if _is_dangerous_path(rel):
|
|
135
|
+
continue
|
|
136
|
+
out.append((rel, full))
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def zip_directory(root: Path, dest: Path) -> None:
|
|
141
|
+
"""Write a deflate zip of all files under root (paths relative to root)."""
|
|
142
|
+
files = _iter_directory_files(root)
|
|
143
|
+
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
144
|
+
for arcname, file_path in files:
|
|
145
|
+
zf.write(file_path, arcname)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def submission_zip_requirement_violations(zip_path: Path) -> list[str]:
|
|
149
|
+
reasons: list[str] = []
|
|
150
|
+
try:
|
|
151
|
+
zf = zipfile.ZipFile(zip_path, "r")
|
|
152
|
+
except zipfile.BadZipFile:
|
|
153
|
+
return ["invalid submission.zip"]
|
|
154
|
+
try:
|
|
155
|
+
infos = zf.infolist()
|
|
156
|
+
if not infos:
|
|
157
|
+
return ["submission.zip is empty"]
|
|
158
|
+
nonempty = 0
|
|
159
|
+
for i in infos:
|
|
160
|
+
if i.is_dir():
|
|
161
|
+
continue
|
|
162
|
+
if _is_dangerous_path(i.filename):
|
|
163
|
+
reasons.append(f"Unsafe path (zip-slip risk): {i.filename!r}")
|
|
164
|
+
if _suffix(i.filename) in BLOCKED_SUFFIXES:
|
|
165
|
+
reasons.append(f"Blocked file type {_suffix(i.filename)} in archive: {i.filename}")
|
|
166
|
+
if i.file_size > 0:
|
|
167
|
+
nonempty += 1
|
|
168
|
+
if nonempty == 0:
|
|
169
|
+
return ["submission.zip has no non-empty files"]
|
|
170
|
+
if len(infos) > MAX_FILES_IN_SUBMISSION:
|
|
171
|
+
reasons.append(f"Too many files in archive ({len(infos)} > {MAX_FILES_IN_SUBMISSION})")
|
|
172
|
+
finally:
|
|
173
|
+
zf.close()
|
|
174
|
+
return reasons[:80]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def submission_zip_secret_findings(zip_path: Path) -> list[str]:
|
|
178
|
+
findings: list[str] = []
|
|
179
|
+
try:
|
|
180
|
+
zf = zipfile.ZipFile(zip_path, "r")
|
|
181
|
+
except zipfile.BadZipFile:
|
|
182
|
+
return []
|
|
183
|
+
try:
|
|
184
|
+
for i in zf.infolist():
|
|
185
|
+
if i.is_dir() or i.file_size > 512_000:
|
|
186
|
+
continue
|
|
187
|
+
if _suffix(i.filename) not in SECRET_TEXT_SUFFIXES and _suffix(i.filename) != "":
|
|
188
|
+
continue
|
|
189
|
+
try:
|
|
190
|
+
data = zf.read(i.filename)
|
|
191
|
+
except Exception:
|
|
192
|
+
continue
|
|
193
|
+
for pat in SECRET_PATTERNS:
|
|
194
|
+
if pat.search(data):
|
|
195
|
+
findings.append(f"Possible secret material in {i.filename} — manual review")
|
|
196
|
+
break
|
|
197
|
+
finally:
|
|
198
|
+
zf.close()
|
|
199
|
+
return findings[:50]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def submission_directory_requirement_violations(root: Path) -> list[str]:
|
|
203
|
+
reasons: list[str] = []
|
|
204
|
+
files = _iter_directory_files(root)
|
|
205
|
+
if not files:
|
|
206
|
+
return ["submission folder is empty"]
|
|
207
|
+
nonempty = 0
|
|
208
|
+
for rel, path in files:
|
|
209
|
+
if _is_dangerous_path(rel):
|
|
210
|
+
reasons.append(f"Unsafe path: {rel!r}")
|
|
211
|
+
if _suffix(rel) in BLOCKED_SUFFIXES:
|
|
212
|
+
reasons.append(f"Blocked file type {_suffix(rel)}: {rel}")
|
|
213
|
+
try:
|
|
214
|
+
if path.stat().st_size > 0:
|
|
215
|
+
nonempty += 1
|
|
216
|
+
except OSError:
|
|
217
|
+
continue
|
|
218
|
+
if nonempty == 0:
|
|
219
|
+
return ["submission folder has no non-empty files"]
|
|
220
|
+
if len(files) > MAX_FILES_IN_SUBMISSION:
|
|
221
|
+
reasons.append(f"Too many files in submission folder ({len(files)} > {MAX_FILES_IN_SUBMISSION})")
|
|
222
|
+
return reasons[:80]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def submission_directory_secret_findings(root: Path) -> list[str]:
|
|
226
|
+
findings: list[str] = []
|
|
227
|
+
for rel, path in _iter_directory_files(root):
|
|
228
|
+
if _suffix(rel) not in SECRET_TEXT_SUFFIXES and _suffix(rel) != "":
|
|
229
|
+
continue
|
|
230
|
+
try:
|
|
231
|
+
if path.stat().st_size > 512_000:
|
|
232
|
+
continue
|
|
233
|
+
data = path.read_bytes()
|
|
234
|
+
except OSError:
|
|
235
|
+
continue
|
|
236
|
+
for pat in SECRET_PATTERNS:
|
|
237
|
+
if pat.search(data):
|
|
238
|
+
findings.append(f"Possible secret material in {rel} — manual review")
|
|
239
|
+
break
|
|
240
|
+
return findings[:50]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _print_requirement_errors(label: str, errors: list[str]) -> None:
|
|
244
|
+
console.print(f"[red]{label} does not meet requirements[/red]:")
|
|
245
|
+
for line in errors[:20]:
|
|
246
|
+
console.print(f" - {line}")
|
|
247
|
+
if len(errors) > 20:
|
|
248
|
+
console.print(f" ... and {len(errors) - 20} more")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _assert_zip(path: Path, label: str) -> zipfile.ZipFile:
|
|
252
|
+
if not path.is_file():
|
|
253
|
+
console.print(f"[red]Not a zip file[/red]: {label} ({path})")
|
|
254
|
+
raise SystemExit(1)
|
|
255
|
+
try:
|
|
256
|
+
return zipfile.ZipFile(path, "r")
|
|
257
|
+
except zipfile.BadZipFile:
|
|
258
|
+
console.print(f"[red]Not a valid zip file[/red]: {label} ({path})")
|
|
259
|
+
raise SystemExit(1) from None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _submission_zip_non_empty(zf: zipfile.ZipFile) -> bool:
|
|
263
|
+
return any(not info.is_dir() and info.file_size > 0 for info in zf.infolist())
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def scan_assets_zip(zf: zipfile.ZipFile) -> dict[str, Any]:
|
|
267
|
+
"""Count files by category; ignore directory entries."""
|
|
268
|
+
counts = {"images": 0, "videos": 0, "pdfs": 0}
|
|
269
|
+
sizes: dict[str, list[tuple[str, int]]] = {"images": [], "videos": [], "pdfs": []}
|
|
270
|
+
unsupported: list[str] = []
|
|
271
|
+
for info in zf.infolist():
|
|
272
|
+
if info.is_dir() or info.filename.endswith("/"):
|
|
273
|
+
continue
|
|
274
|
+
if _is_dangerous_path(info.filename):
|
|
275
|
+
unsupported.append(f"Unsafe path in assets.zip: {info.filename!r}")
|
|
276
|
+
continue
|
|
277
|
+
ext = Path(Path(info.filename).name).suffix.lower()
|
|
278
|
+
size = info.file_size
|
|
279
|
+
if ext in IMAGE_EXTS:
|
|
280
|
+
counts["images"] += 1
|
|
281
|
+
sizes["images"].append((info.filename, size))
|
|
282
|
+
elif ext in VIDEO_EXTS:
|
|
283
|
+
counts["videos"] += 1
|
|
284
|
+
sizes["videos"].append((info.filename, size))
|
|
285
|
+
elif ext in PDF_EXTS:
|
|
286
|
+
counts["pdfs"] += 1
|
|
287
|
+
sizes["pdfs"].append((info.filename, size))
|
|
288
|
+
else:
|
|
289
|
+
unsupported.append(f"Unsupported asset type in assets.zip: {info.filename}")
|
|
290
|
+
return {"counts": counts, "sizes": sizes, "unsupported": unsupported}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def scan_assets_directory(root: Path) -> dict[str, Any]:
|
|
294
|
+
counts = {"images": 0, "videos": 0, "pdfs": 0}
|
|
295
|
+
sizes: dict[str, list[tuple[str, int]]] = {"images": [], "videos": [], "pdfs": []}
|
|
296
|
+
unsupported: list[str] = []
|
|
297
|
+
total_uncompressed = 0
|
|
298
|
+
|
|
299
|
+
files = _iter_directory_files(root)
|
|
300
|
+
if not files:
|
|
301
|
+
unsupported.append("assets folder is empty")
|
|
302
|
+
return {"counts": counts, "sizes": sizes, "unsupported": unsupported}
|
|
303
|
+
|
|
304
|
+
if len(files) > MAX_FILES_IN_ASSETS:
|
|
305
|
+
unsupported.append(f"Too many files in assets folder ({len(files)} > {MAX_FILES_IN_ASSETS})")
|
|
306
|
+
|
|
307
|
+
for rel, path in files:
|
|
308
|
+
try:
|
|
309
|
+
size = path.stat().st_size
|
|
310
|
+
except OSError as e:
|
|
311
|
+
unsupported.append(f"Could not read {rel}: {e}")
|
|
312
|
+
continue
|
|
313
|
+
total_uncompressed += size
|
|
314
|
+
ext = _suffix(rel)
|
|
315
|
+
if ext in IMAGE_EXTS:
|
|
316
|
+
counts["images"] += 1
|
|
317
|
+
sizes["images"].append((rel, size))
|
|
318
|
+
elif ext in VIDEO_EXTS:
|
|
319
|
+
counts["videos"] += 1
|
|
320
|
+
sizes["videos"].append((rel, size))
|
|
321
|
+
elif ext in PDF_EXTS:
|
|
322
|
+
counts["pdfs"] += 1
|
|
323
|
+
sizes["pdfs"].append((rel, size))
|
|
324
|
+
else:
|
|
325
|
+
unsupported.append(f"Unsupported asset type in assets folder: {rel}")
|
|
326
|
+
|
|
327
|
+
if total_uncompressed > MAX_TOTAL_ASSETS_UNCOMPRESSED:
|
|
328
|
+
unsupported.append("assets folder uncompressed size too large")
|
|
329
|
+
|
|
330
|
+
return {"counts": counts, "sizes": sizes, "unsupported": unsupported}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _limit_int(limits: dict[str, Any], key: str) -> int | None:
|
|
334
|
+
v = limits.get(key)
|
|
335
|
+
if v is None:
|
|
336
|
+
return None
|
|
337
|
+
try:
|
|
338
|
+
return int(v)
|
|
339
|
+
except (TypeError, ValueError):
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def check_assets_against_limits(scan: dict[str, Any], limits: dict[str, Any]) -> None:
|
|
344
|
+
"""Raise SystemExit if any asset limit is exceeded."""
|
|
345
|
+
unsupported = scan.get("unsupported") or []
|
|
346
|
+
if unsupported:
|
|
347
|
+
console.print("[red]assets do not meet requirements[/red]:")
|
|
348
|
+
for line in unsupported[:20]:
|
|
349
|
+
console.print(f" - {line}")
|
|
350
|
+
if len(unsupported) > 20:
|
|
351
|
+
console.print(f" ... and {len(unsupported) - 20} more")
|
|
352
|
+
raise SystemExit(1)
|
|
353
|
+
|
|
354
|
+
c = scan["counts"]
|
|
355
|
+
max_img = _limit_int(limits, "max_images")
|
|
356
|
+
max_vid = _limit_int(limits, "max_videos")
|
|
357
|
+
max_pdf = _limit_int(limits, "max_pdfs")
|
|
358
|
+
max_img_mb = _limit_int(limits, "max_image_size_mb")
|
|
359
|
+
max_vid_mb = _limit_int(limits, "max_video_size_mb")
|
|
360
|
+
max_pdf_mb = _limit_int(limits, "max_pdf_size_mb")
|
|
361
|
+
|
|
362
|
+
if max_img is not None and c["images"] > max_img:
|
|
363
|
+
console.print(f"[red]assets exceed image limit[/red]: found {c['images']}, limit is {max_img}.")
|
|
364
|
+
raise SystemExit(1)
|
|
365
|
+
if max_vid is not None and c["videos"] > max_vid:
|
|
366
|
+
console.print(f"[red]assets exceed video limit[/red]: found {c['videos']}, limit is {max_vid}.")
|
|
367
|
+
raise SystemExit(1)
|
|
368
|
+
if max_pdf is not None and c["pdfs"] > max_pdf:
|
|
369
|
+
console.print(f"[red]assets exceed PDF limit[/red]: found {c['pdfs']}, limit is {max_pdf}.")
|
|
370
|
+
raise SystemExit(1)
|
|
371
|
+
|
|
372
|
+
mb = 1024 * 1024
|
|
373
|
+
|
|
374
|
+
def _check_sizes(key_mb: int | None, pairs: list[tuple[str, int]], label: str) -> None:
|
|
375
|
+
if key_mb is None:
|
|
376
|
+
return
|
|
377
|
+
max_bytes = key_mb * mb
|
|
378
|
+
for name, sz in pairs:
|
|
379
|
+
if sz > max_bytes:
|
|
380
|
+
console.print(
|
|
381
|
+
f"[red]{label} too large[/red]: {name} is {sz / mb:.1f} MB, limit is {key_mb} MB."
|
|
382
|
+
)
|
|
383
|
+
raise SystemExit(1)
|
|
384
|
+
|
|
385
|
+
_check_sizes(max_img_mb, scan["sizes"]["images"], "Image")
|
|
386
|
+
_check_sizes(max_vid_mb, scan["sizes"]["videos"], "Video")
|
|
387
|
+
_check_sizes(max_pdf_mb, scan["sizes"]["pdfs"], "PDF")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _max_submission_zip_bytes(limits: dict[str, Any]) -> int:
|
|
391
|
+
mb = _limit_int(limits, "max_submission_zip_mb")
|
|
392
|
+
if mb is not None and mb > 0:
|
|
393
|
+
return mb * 1024 * 1024
|
|
394
|
+
return DEFAULT_MAX_SUBMISSION_ZIP_BYTES
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _validate_submission_input(path: Path) -> None:
|
|
398
|
+
if path.is_dir():
|
|
399
|
+
req = submission_directory_requirement_violations(path)
|
|
400
|
+
if req:
|
|
401
|
+
_print_requirement_errors("Submission folder", req)
|
|
402
|
+
raise SystemExit(1)
|
|
403
|
+
sec = submission_directory_secret_findings(path)
|
|
404
|
+
if sec:
|
|
405
|
+
_print_requirement_errors("Submission folder", sec)
|
|
406
|
+
raise SystemExit(1)
|
|
407
|
+
return
|
|
408
|
+
zf = _assert_zip(path, "submission")
|
|
409
|
+
try:
|
|
410
|
+
if not _submission_zip_non_empty(zf):
|
|
411
|
+
console.print("[red]submission.zip is empty[/red] — add project files before zipping.")
|
|
412
|
+
raise SystemExit(1)
|
|
413
|
+
finally:
|
|
414
|
+
zf.close()
|
|
415
|
+
req = submission_zip_requirement_violations(path)
|
|
416
|
+
if req:
|
|
417
|
+
_print_requirement_errors("submission.zip", req)
|
|
418
|
+
raise SystemExit(1)
|
|
419
|
+
sec = submission_zip_secret_findings(path)
|
|
420
|
+
if sec:
|
|
421
|
+
_print_requirement_errors("submission.zip", sec)
|
|
422
|
+
raise SystemExit(1)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _scan_assets_input(path: Path) -> dict[str, Any]:
|
|
426
|
+
if path.is_dir():
|
|
427
|
+
return scan_assets_directory(path)
|
|
428
|
+
zf = _assert_zip(path, "assets")
|
|
429
|
+
try:
|
|
430
|
+
return scan_assets_zip(zf)
|
|
431
|
+
finally:
|
|
432
|
+
zf.close()
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _zip_input_to_temp(path: Path, prefix: str) -> Path:
|
|
436
|
+
fd, name = tempfile.mkstemp(prefix=prefix, suffix=".zip")
|
|
437
|
+
os.close(fd)
|
|
438
|
+
dest = Path(name)
|
|
439
|
+
console.print(f"Zipping {path.name} ...")
|
|
440
|
+
zip_directory(path, dest)
|
|
441
|
+
return dest
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def validate_submission_thumbnail(path: Path, *, max_image_size_mb: int) -> tuple[bytes, str, str]:
|
|
445
|
+
"""Return (raw bytes, content_type, filename) or raise SystemExit."""
|
|
446
|
+
if not path.is_file():
|
|
447
|
+
console.print(f"[red]Path not found[/red]: {path}")
|
|
448
|
+
raise SystemExit(1)
|
|
449
|
+
ext_to_mime = {
|
|
450
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
|
451
|
+
".webp": "image/webp", ".gif": "image/gif",
|
|
452
|
+
}
|
|
453
|
+
content_type = ext_to_mime.get(path.suffix.lower())
|
|
454
|
+
if not content_type:
|
|
455
|
+
console.print("[red]Unsupported thumbnail type[/red] — use JPEG, PNG, WebP, or GIF.")
|
|
456
|
+
raise SystemExit(1)
|
|
457
|
+
try:
|
|
458
|
+
size = path.stat().st_size
|
|
459
|
+
except OSError as e:
|
|
460
|
+
console.print(f"[red]Could not read thumbnail[/red]: {e}")
|
|
461
|
+
raise SystemExit(1)
|
|
462
|
+
max_bytes = max_image_size_mb * 1024 * 1024
|
|
463
|
+
if size <= 0:
|
|
464
|
+
console.print("[red]Thumbnail file is empty[/red].")
|
|
465
|
+
raise SystemExit(1)
|
|
466
|
+
if size > max_bytes:
|
|
467
|
+
console.print(
|
|
468
|
+
f"[red]Thumbnail too large[/red]: {size / (1024*1024):.1f} MB, limit is {max_image_size_mb} MB."
|
|
469
|
+
)
|
|
470
|
+
raise SystemExit(1)
|
|
471
|
+
try:
|
|
472
|
+
return path.read_bytes(), content_type, path.name
|
|
473
|
+
except OSError as e:
|
|
474
|
+
console.print(f"[red]Could not read thumbnail[/red]: {e}")
|
|
475
|
+
raise SystemExit(1)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def submit(
|
|
479
|
+
api_key: str,
|
|
480
|
+
submission_path: Path,
|
|
481
|
+
assets_path: Path,
|
|
482
|
+
base_url: str,
|
|
483
|
+
*,
|
|
484
|
+
title: str,
|
|
485
|
+
description: str = "",
|
|
486
|
+
thumbnail: Path | None = None,
|
|
487
|
+
public_source: bool = False,
|
|
488
|
+
hackathon_id: str | None = None,
|
|
489
|
+
) -> int:
|
|
490
|
+
"""Validate, preflight, and upload a hackathon submission. Returns exit code."""
|
|
491
|
+
validate_api_key_format(api_key)
|
|
492
|
+
title_clean, description_clean = validate_submission_metadata(title, description)
|
|
493
|
+
|
|
494
|
+
_require_existing_path(submission_path, "submission")
|
|
495
|
+
_require_existing_path(assets_path, "assets")
|
|
496
|
+
|
|
497
|
+
console.print("Checking submission...")
|
|
498
|
+
_validate_submission_input(submission_path)
|
|
499
|
+
|
|
500
|
+
console.print("Checking assets...")
|
|
501
|
+
assets_scan = _scan_assets_input(assets_path)
|
|
502
|
+
if assets_scan.get("unsupported"):
|
|
503
|
+
check_assets_against_limits(assets_scan, {})
|
|
504
|
+
|
|
505
|
+
console.print("Verifying your API key and submission eligibility...")
|
|
506
|
+
pre_url = _preflight_url(base_url)
|
|
507
|
+
json_body: dict[str, Any] = {"api_key": api_key}
|
|
508
|
+
if hackathon_id and str(hackathon_id).strip():
|
|
509
|
+
json_body["hackathon_id"] = str(hackathon_id).strip()
|
|
510
|
+
|
|
511
|
+
with httpx.Client() as client:
|
|
512
|
+
try:
|
|
513
|
+
pr = request_json(client, "POST", pre_url, json_body=json_body)
|
|
514
|
+
except httpx.RequestError as e:
|
|
515
|
+
console.print(f"[red]Unable to reach the Nepher backend[/red]. Check your network connection. ({e})")
|
|
516
|
+
return 1
|
|
517
|
+
|
|
518
|
+
if pr.status_code != 200:
|
|
519
|
+
err = parse_error_body(pr.text) or pr.text.strip() or f"HTTP {pr.status_code}"
|
|
520
|
+
try:
|
|
521
|
+
err_obj = pr.json()
|
|
522
|
+
except json.JSONDecodeError:
|
|
523
|
+
err_obj = None
|
|
524
|
+
if (
|
|
525
|
+
isinstance(err_obj, dict)
|
|
526
|
+
and err_obj.get("code") == "multiple_hackathons"
|
|
527
|
+
and isinstance(err_obj.get("hackathons"), list)
|
|
528
|
+
):
|
|
529
|
+
console.print(f"[red]{err}[/red]")
|
|
530
|
+
console.print("[bold]Open submission windows:[/bold]")
|
|
531
|
+
for row in err_obj["hackathons"]:
|
|
532
|
+
if isinstance(row, dict):
|
|
533
|
+
console.print(f" - [cyan]{row.get('id', '?')}[/cyan] — {row.get('title', '?')}")
|
|
534
|
+
console.print("\n[dim]Re-run with[/dim] [bold]--hackathon-id <UUID>[/bold] [dim]to choose one.[/dim]")
|
|
535
|
+
else:
|
|
536
|
+
console.print(f"[red]{err}[/red]")
|
|
537
|
+
return 1
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
pre_body = pr.json()
|
|
541
|
+
except json.JSONDecodeError:
|
|
542
|
+
console.print("[red]Unexpected preflight response[/red] (invalid JSON).")
|
|
543
|
+
return 1
|
|
544
|
+
|
|
545
|
+
if not isinstance(pre_body, dict) or pre_body.get("status") != "ok":
|
|
546
|
+
console.print("[red]Preflight did not return status ok.[/red]")
|
|
547
|
+
return 1
|
|
548
|
+
|
|
549
|
+
hid = pre_body.get("hackathon_id")
|
|
550
|
+
htitle = pre_body.get("hackathon_title")
|
|
551
|
+
if hid:
|
|
552
|
+
title_bit = f" — {htitle}" if isinstance(htitle, str) and htitle.strip() else ""
|
|
553
|
+
console.print(f"Hackathon: [bold]{hid}[/bold]{title_bit}")
|
|
554
|
+
|
|
555
|
+
limits = pre_body.get("limits")
|
|
556
|
+
if not isinstance(limits, dict):
|
|
557
|
+
console.print("[red]Preflight missing limits.[/red]")
|
|
558
|
+
return 1
|
|
559
|
+
|
|
560
|
+
console.print("Validating assets against hackathon limits...")
|
|
561
|
+
check_assets_against_limits(assets_scan, limits)
|
|
562
|
+
|
|
563
|
+
thumb_payload: tuple[bytes, str, str] | None = None
|
|
564
|
+
if thumbnail is not None:
|
|
565
|
+
max_img_mb = _limit_int(limits, "max_image_size_mb") or 10
|
|
566
|
+
thumb_payload = validate_submission_thumbnail(thumbnail, max_image_size_mb=max_img_mb)
|
|
567
|
+
|
|
568
|
+
_print_quota_line("Eligible now", pre_body)
|
|
569
|
+
|
|
570
|
+
cleanup: list[Path] = []
|
|
571
|
+
try:
|
|
572
|
+
sub_zip = _zip_input_to_temp(submission_path, "nepher-submission-") if submission_path.is_dir() else submission_path
|
|
573
|
+
if submission_path.is_dir():
|
|
574
|
+
cleanup.append(sub_zip)
|
|
575
|
+
|
|
576
|
+
ast_zip = _zip_input_to_temp(assets_path, "nepher-assets-") if assets_path.is_dir() else assets_path
|
|
577
|
+
if assets_path.is_dir():
|
|
578
|
+
cleanup.append(ast_zip)
|
|
579
|
+
|
|
580
|
+
max_sub_bytes = _max_submission_zip_bytes(limits)
|
|
581
|
+
sub_size = sub_zip.stat().st_size
|
|
582
|
+
if sub_size > max_sub_bytes:
|
|
583
|
+
max_mb = max_sub_bytes // (1024 * 1024)
|
|
584
|
+
console.print(f"[red]submission.zip is too large[/red] ({sub_size / (1024*1024):.1f} MB, max {max_mb} MB).")
|
|
585
|
+
return 1
|
|
586
|
+
|
|
587
|
+
ast_size = ast_zip.stat().st_size
|
|
588
|
+
if ast_size > max_sub_bytes:
|
|
589
|
+
max_mb = max_sub_bytes // (1024 * 1024)
|
|
590
|
+
console.print(f"[red]assets.zip is too large[/red] ({ast_size / (1024*1024):.1f} MB, max {max_mb} MB).")
|
|
591
|
+
return 1
|
|
592
|
+
|
|
593
|
+
upload_bits = f"submission.zip ({sub_size/(1024*1024):.1f} MB) and assets.zip ({ast_size/(1024*1024):.1f} MB)"
|
|
594
|
+
if thumb_payload:
|
|
595
|
+
upload_bits += f" and thumbnail ({thumb_payload[2]})"
|
|
596
|
+
console.print(f"Uploading {upload_bits}...")
|
|
597
|
+
|
|
598
|
+
form_data: dict[str, str] = {
|
|
599
|
+
"api_key": api_key,
|
|
600
|
+
"title": title_clean,
|
|
601
|
+
"description": description_clean,
|
|
602
|
+
"submitter_public_source": "true" if public_source else "false",
|
|
603
|
+
}
|
|
604
|
+
if hackathon_id and str(hackathon_id).strip():
|
|
605
|
+
form_data["hackathon_id"] = str(hackathon_id).strip()
|
|
606
|
+
|
|
607
|
+
with httpx.Client() as client:
|
|
608
|
+
try:
|
|
609
|
+
with open(sub_zip, "rb") as sf, open(ast_zip, "rb") as af:
|
|
610
|
+
files: dict[str, tuple[str, object, str]] = {
|
|
611
|
+
"submission": ("submission.zip", sf, "application/zip"),
|
|
612
|
+
"assets": ("assets.zip", af, "application/zip"),
|
|
613
|
+
}
|
|
614
|
+
if thumb_payload:
|
|
615
|
+
raw, mime, fname = thumb_payload
|
|
616
|
+
files["thumbnail"] = (fname, raw, mime)
|
|
617
|
+
ur = client.post(_upload_url(base_url), data=form_data, files=files, timeout=600.0)
|
|
618
|
+
except OSError as e:
|
|
619
|
+
console.print(f"[red]Could not read zip files[/red]: {e}")
|
|
620
|
+
return 1
|
|
621
|
+
except httpx.RequestError as e:
|
|
622
|
+
console.print(f"[red]Unable to reach the Nepher backend[/red]. Check your network connection. ({e})")
|
|
623
|
+
return 1
|
|
624
|
+
|
|
625
|
+
if ur.status_code in (200, 201):
|
|
626
|
+
try:
|
|
627
|
+
ub = ur.json()
|
|
628
|
+
except json.JSONDecodeError:
|
|
629
|
+
console.print("[red]Unexpected upload response[/red] (invalid JSON).")
|
|
630
|
+
return 1
|
|
631
|
+
if isinstance(ub, dict):
|
|
632
|
+
console.print("[green]Submission uploaded successfully.[/green]")
|
|
633
|
+
console.print(f" Submission ID: [bold]{ub.get('submission_id', '?')}[/bold]")
|
|
634
|
+
console.print(f" Status: {ub.get('status', '?')} (pending review)")
|
|
635
|
+
if ub.get("message"):
|
|
636
|
+
console.print(f" {ub['message']}")
|
|
637
|
+
_print_quota_line("Remaining after this upload", ub)
|
|
638
|
+
console.print("\n[dim]Your submission is now being reviewed. Check your dashboard for updates.[/dim]")
|
|
639
|
+
return 0
|
|
640
|
+
|
|
641
|
+
err = parse_error_body(ur.text) or ur.text.strip() or f"HTTP {ur.status_code}"
|
|
642
|
+
console.print(f"[red]{err}[/red]")
|
|
643
|
+
return 1
|
|
644
|
+
finally:
|
|
645
|
+
for p in cleanup:
|
|
646
|
+
p.unlink(missing_ok=True)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# ---------------------------------------------------------------------------
|
|
650
|
+
# Click command group
|
|
651
|
+
# ---------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@click.group("hackathon")
|
|
655
|
+
def hackathon() -> None:
|
|
656
|
+
"""Browse and submit to Nepher hackathons."""
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@hackathon.command("list")
|
|
660
|
+
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON.")
|
|
661
|
+
def hackathon_list(output_json: bool) -> None:
|
|
662
|
+
"""List all hackathons (open, upcoming, and completed).
|
|
663
|
+
|
|
664
|
+
The endpoint is public — no authentication required.
|
|
665
|
+
"""
|
|
666
|
+
url = f"{HACKATHON_BACKEND.rstrip('/')}/api/v1/hackathons/"
|
|
667
|
+
try:
|
|
668
|
+
r = httpx.get(url, timeout=30.0)
|
|
669
|
+
except httpx.RequestError as e:
|
|
670
|
+
console.print(f"[red]Unable to reach the Nepher backend[/red] ({e}).")
|
|
671
|
+
raise SystemExit(1) from e
|
|
672
|
+
|
|
673
|
+
if r.status_code != 200:
|
|
674
|
+
console.print(f"[red]{parse_error_body(r.text) or r.text.strip() or f'HTTP {r.status_code}'}[/red]")
|
|
675
|
+
raise SystemExit(1)
|
|
676
|
+
|
|
677
|
+
try:
|
|
678
|
+
data = r.json()
|
|
679
|
+
except Exception:
|
|
680
|
+
console.print("[red]Unexpected response (invalid JSON).[/red]")
|
|
681
|
+
raise SystemExit(1)
|
|
682
|
+
|
|
683
|
+
if output_json:
|
|
684
|
+
click.echo(json.dumps(data, indent=2))
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
items: list[dict[str, Any]] = data if isinstance(data, list) else data.get("results", data.get("hackathons", []))
|
|
688
|
+
|
|
689
|
+
if not items:
|
|
690
|
+
console.print("[dim]No hackathons found.[/dim]")
|
|
691
|
+
return
|
|
692
|
+
|
|
693
|
+
table = Table(show_header=True, header_style="bold", box=None)
|
|
694
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
695
|
+
table.add_column("Title")
|
|
696
|
+
table.add_column("Phase")
|
|
697
|
+
table.add_column("Submission Deadline")
|
|
698
|
+
|
|
699
|
+
for h in items:
|
|
700
|
+
table.add_row(
|
|
701
|
+
str(h.get("id", "")),
|
|
702
|
+
h.get("title") or "—",
|
|
703
|
+
h.get("current_phase") or h.get("phase") or "—",
|
|
704
|
+
str(h.get("submission_deadline") or h.get("submission_end") or "—"),
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
from rich import print as rprint
|
|
708
|
+
rprint(table)
|
|
709
|
+
console.print(f"\n[dim]{len(items)} hackathon(s) listed.[/dim]")
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@hackathon.command("submit")
|
|
713
|
+
@click.option(
|
|
714
|
+
"--api-key", "--apikey", "api_key",
|
|
715
|
+
default=None, envvar="NEPHER_API_KEY", metavar="KEY",
|
|
716
|
+
help="Nepher API key (nepher_...). Falls back to stored credentials.",
|
|
717
|
+
)
|
|
718
|
+
@click.option("--hackathon-id", default=None, metavar="UUID", help="Target hackathon UUID (required when multiple are open).")
|
|
719
|
+
@click.option("--submission", required=True, metavar="PATH", help="Project folder or existing submission.zip.")
|
|
720
|
+
@click.option("--assets", required=True, metavar="PATH", help="Assets folder or assets.zip (images, videos, PDFs only).")
|
|
721
|
+
@click.option("--title", required=True, metavar="TEXT", help="Entry title (max 200 characters).")
|
|
722
|
+
@click.option("--description", default="", metavar="TEXT", help="Optional Markdown description.")
|
|
723
|
+
@click.option("--thumbnail", default=None, metavar="PATH", help="Optional listing image (JPEG, PNG, WebP, or GIF).")
|
|
724
|
+
@click.option("--public-source", is_flag=True, help="Opt in to public source download when the event allows it.")
|
|
725
|
+
def hackathon_submit(
|
|
726
|
+
api_key: str | None,
|
|
727
|
+
hackathon_id: str | None,
|
|
728
|
+
submission: str,
|
|
729
|
+
assets: str,
|
|
730
|
+
title: str,
|
|
731
|
+
description: str,
|
|
732
|
+
thumbnail: str | None,
|
|
733
|
+
public_source: bool,
|
|
734
|
+
) -> None:
|
|
735
|
+
"""Upload a project and assets to a hackathon.
|
|
736
|
+
|
|
737
|
+
The CLI validates your files locally, runs a preflight check against the
|
|
738
|
+
hackathon's limits, then uploads submission.zip and assets.zip.
|
|
739
|
+
"""
|
|
740
|
+
resolved_key = api_key or get_stored_api_key()
|
|
741
|
+
if not resolved_key:
|
|
742
|
+
console.print(
|
|
743
|
+
"[red]No API key available.[/red] "
|
|
744
|
+
"Pass [bold]--api-key[/bold] or run [bold]npcli account login[/bold] first."
|
|
745
|
+
)
|
|
746
|
+
raise SystemExit(1)
|
|
747
|
+
|
|
748
|
+
raise SystemExit(
|
|
749
|
+
submit(
|
|
750
|
+
resolved_key,
|
|
751
|
+
Path(submission),
|
|
752
|
+
Path(assets),
|
|
753
|
+
HACKATHON_BACKEND,
|
|
754
|
+
title=title,
|
|
755
|
+
description=description,
|
|
756
|
+
thumbnail=Path(thumbnail) if thumbnail else None,
|
|
757
|
+
public_source=public_source,
|
|
758
|
+
hackathon_id=hackathon_id,
|
|
759
|
+
)
|
|
760
|
+
)
|