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.
@@ -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
+ )