arozantsev commited on
Commit
f55fa8d
Β·
verified Β·
1 Parent(s): 16f8498

Deploy simready-assets run 20261005-ee37df6 (GPU variant) (part 2)

Browse files
pipeline/bake.py CHANGED
@@ -19,6 +19,7 @@ import subprocess
19
  from pathlib import Path
20
  from typing import Dict, List, Optional
21
 
 
22
  from .artifact_store import artifact_env
23
  from .config import (
24
  BUILD_DIR,
@@ -185,6 +186,32 @@ def _image_names(cfg: Config) -> Dict[str, str]:
185
  return names
186
 
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  def hf_space_build_args(cfg: Config, manifest: Dict) -> Dict[str, str]:
189
  """The `docker/Dockerfile.hf-space` ARGs this run needs, shared between
190
  the local/CI `docker build` (bake.run, as --build-arg) and pipeline/
@@ -216,6 +243,13 @@ def hf_space_build_args(cfg: Config, manifest: Dict) -> Dict[str, str]:
216
  "SIGLIP2_NVCF_ENDPOINT": cfg.get("SIGLIP2_NVCF_ENDPOINT", SIGLIP2_NVCF_ENDPOINT_DEFAULT),
217
  "SIGLIP2_NVCF_FUNCTION_ID": cfg.get("SIGLIP2_NVCF_FUNCTION_ID", SIGLIP2_NVCF_FUNCTION_ID_DEFAULT),
218
  }
 
 
 
 
 
 
 
219
  # S3_STORAGE_* is only ever meaningful for an S3-sourced corpus, and
220
  # threading a source bucket name through for an HF-sourced one would be
221
  # actively misleading.
@@ -395,6 +429,48 @@ def download_smoke(base_url: str, timeout_s: float = 180.0) -> bool:
395
  return False
396
 
397
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  def verify_baked(cfg: Config, tags: Dict[str, str], manifest: Dict) -> int:
399
  """Check the data from a fresh `docker run`, not from the build log.
400
 
@@ -489,7 +565,8 @@ def verify_baked(cfg: Config, tags: Dict[str, str], manifest: Dict) -> int:
489
  # this smoke test is what actually proves that path works, not just
490
  # that the image builds. A longer deadline than the other images'
491
  # health checks: startup now includes a real S3 download + bulk
492
- # load, not just a process coming up.
 
493
  token = cfg.siglip2_nvcf_env()["SIGLIP2_TRITON_SERVER_AUTH_TOKEN"]
494
  aws = artifact_env()
495
  result = subprocess.run(
@@ -497,6 +574,7 @@ def verify_baked(cfg: Config, tags: Dict[str, str], manifest: Dict) -> int:
497
  "-e", f"SIGLIP2_TRITON_SERVER_AUTH_TOKEN={token}",
498
  "-e", f"SIMREADYCENTRAL_INDEX_STORE_AWS_ACCESS_KEY_ID={aws['AWS_ACCESS_KEY_ID']}",
499
  "-e", f"SIMREADYCENTRAL_INDEX_STORE_AWS_SECRET_ACCESS_KEY={aws['AWS_SECRET_ACCESS_KEY']}",
 
500
  tags["hf_space"]],
501
  capture_output=True, text=True, check=True, timeout=60,
502
  )
@@ -569,7 +647,18 @@ def verify_baked(cfg: Config, tags: Dict[str, str], manifest: Dict) -> int:
569
  print(" FAIL: hf-space image's download route (hf-storage-api) does not answer")
570
  ok = False
571
 
572
- manifest["hf_space_smoke_passed"] = healthy and graph_ok and download_ok
 
 
 
 
 
 
 
 
 
 
 
573
  save_manifest(manifest)
574
  finally:
575
  subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False, timeout=30)
@@ -590,6 +679,7 @@ def verify_baked(cfg: Config, tags: Dict[str, str], manifest: Dict) -> int:
590
  ["docker", "run", "--rm", "-d", "-p", "17861:7860", "--gpus", "all",
591
  "-e", f"SIMREADYCENTRAL_INDEX_STORE_AWS_ACCESS_KEY_ID={aws['AWS_ACCESS_KEY_ID']}",
592
  "-e", f"SIMREADYCENTRAL_INDEX_STORE_AWS_SECRET_ACCESS_KEY={aws['AWS_SECRET_ACCESS_KEY']}",
 
593
  tags["hf_space_gpu"]],
594
  capture_output=True, text=True, check=True, timeout=60,
595
  )
@@ -647,7 +737,18 @@ def verify_baked(cfg: Config, tags: Dict[str, str], manifest: Dict) -> int:
647
  print(" FAIL: hf-space-gpu image's download route (hf-storage-api) does not answer")
648
  ok = False
649
 
650
- manifest["hf_space_gpu_smoke_passed"] = healthy and embedding_ok and download_ok
 
 
 
 
 
 
 
 
 
 
 
651
  save_manifest(manifest)
652
  finally:
653
  subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False, timeout=30)
 
19
  from pathlib import Path
20
  from typing import Dict, List, Optional
21
 
22
+ from . import hf_addressing
23
  from .artifact_store import artifact_env
24
  from .config import (
25
  BUILD_DIR,
 
186
  return names
187
 
188
 
189
+ def url_mapping_rules(hf_repos: List[List[str]]) -> List[Dict]:
190
+ """deepsearch-api's USDSEARCH_URL_MAPPING_RULES for the HF repos a run
191
+ indexed (manifest["hf_repos"]: host, repo_type, repo_id, revision): each
192
+ repo's hf:// prefix -> its browsable huggingface.co file-viewer prefix.
193
+
194
+ A pinned revision maps `hf://.../resolve/<sha>/` to `.../blob/<sha>/`, so
195
+ the links show exactly what was indexed. `.../blob/main/`, the public form
196
+ an unpinned corpus emits, stays accepted inbound as an alias (never
197
+ emitted), so links handed out before the pin still resolve. Unpinned
198
+ (`main`) keeps the plain `hf://.../<repo>/` -> `.../blob/main/` rule.
199
+ """
200
+ rules = []
201
+ for host, repo_type, repo_id, revision in hf_repos:
202
+ prefix = hf_addressing.REPO_TYPE_PREFIX.get(repo_type, "")
203
+ web = f"https://{host}/" + "/".join(p for p in (prefix, repo_id) if p)
204
+ rule = {
205
+ "internal": hf_addressing.build(host=host, repo_type=repo_type, repo_id=repo_id,
206
+ revision=revision, is_folder=True),
207
+ "public": f"{web}/blob/{revision}/",
208
+ }
209
+ if hf_addressing.looks_like_sha(revision):
210
+ rule["aliases"] = [f"{web}/blob/main/"]
211
+ rules.append(rule)
212
+ return rules
213
+
214
+
215
  def hf_space_build_args(cfg: Config, manifest: Dict) -> Dict[str, str]:
216
  """The `docker/Dockerfile.hf-space` ARGs this run needs, shared between
217
  the local/CI `docker build` (bake.run, as --build-arg) and pipeline/
 
243
  "SIGLIP2_NVCF_ENDPOINT": cfg.get("SIGLIP2_NVCF_ENDPOINT", SIGLIP2_NVCF_ENDPOINT_DEFAULT),
244
  "SIGLIP2_NVCF_FUNCTION_ID": cfg.get("SIGLIP2_NVCF_FUNCTION_ID", SIGLIP2_NVCF_FUNCTION_ID_DEFAULT),
245
  }
246
+ # The hf:// -> https:// link rewrite (url_mapping_rules) comes from the
247
+ # repos this run actually indexed, at their pinned revision, so it can't
248
+ # go stale when the corpus is re-pinned. As a Space Variable it is both
249
+ # the build-arg and the runtime env var. Off for an s3:// corpus.
250
+ rules = url_mapping_rules(manifest.get("hf_repos") or []) if storage_backend == "storage_api" else []
251
+ args["USDSEARCH_URL_MAPPING_MODE"] = "rules" if rules else "off"
252
+ args["USDSEARCH_URL_MAPPING_RULES"] = json.dumps(rules, separators=(",", ":"))
253
  # S3_STORAGE_* is only ever meaningful for an S3-sourced corpus, and
254
  # threading a source bucket name through for an HF-sourced one would be
255
  # actively misleading.
 
429
  return False
430
 
431
 
432
+ def public_url_smoke(base_url: str, public_prefixes: List[str], timeout_s: float = 180.0) -> tuple:
433
+ """Every asset URL the Space hands out is a public huggingface.co link
434
+ under this run's URL-mapping rule: v2 search `url`s, v3 hybrid
435
+ `base_key`s and a download manifest's file URLs. A bare `https://` check
436
+ is not enough. The stale hand-set rule emitted
437
+ `https://.../blob/main/resolve/<sha>/...` (a 404), which passes it, so
438
+ each URL must start with one of `public_prefixes`
439
+ (url_mapping_rules()'s `public`s). Returns (ok, detail). Polls like
440
+ download_smoke(): the seed restores the index after /health is up."""
441
+ import time
442
+ import httpx
443
+
444
+ def bad(urls):
445
+ return [u for u in urls if not (isinstance(u, str) and u.startswith("https://")
446
+ and u.startswith(tuple(public_prefixes)))]
447
+
448
+ detail = "no URLs returned"
449
+ deadline = time.monotonic() + timeout_s
450
+ while time.monotonic() < deadline:
451
+ try:
452
+ v2 = httpx.get(f"{base_url}/search", params={"description": "", "limit": 25, "return_images": "false"},
453
+ timeout=30.0).json()
454
+ v2 = v2 if isinstance(v2, list) else v2.get("hits", [])
455
+ v3 = httpx.post(f"{base_url}/search_hybrid", json={"description": "", "limit": 25, "return_images": False},
456
+ timeout=30.0).json().get("hits", [])
457
+ urls = [h.get("url") for h in v2] + [(h.get("source") or {}).get("base_key") for h in v3]
458
+ if urls and urls[0]:
459
+ files = httpx.get(f"{base_url}/download/asset", params={"asset_url": urls[0], "manifest_only": "true"},
460
+ timeout=60.0).json().get("files", [])
461
+ urls += [f.get("url") for f in files]
462
+ wrong = bad(urls)
463
+ if not wrong and v2 and v3 and files:
464
+ return True, f"{len(urls)} URLs, all under {', '.join(public_prefixes)}"
465
+ if wrong:
466
+ return False, f"{len(wrong)}/{len(urls)} URLs not under {', '.join(public_prefixes)}, e.g. {wrong[0]}"
467
+ detail = f"incomplete response (v2 {len(v2)}, v3 {len(v3)}, download files {len(files)})"
468
+ except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError, AttributeError) as exc:
469
+ detail = f"{type(exc).__name__}: {exc}"
470
+ time.sleep(3)
471
+ return False, detail
472
+
473
+
474
  def verify_baked(cfg: Config, tags: Dict[str, str], manifest: Dict) -> int:
475
  """Check the data from a fresh `docker run`, not from the build log.
476
 
 
565
  # this smoke test is what actually proves that path works, not just
566
  # that the image builds. A longer deadline than the other images'
567
  # health checks: startup now includes a real S3 download + bulk
568
+ # load, not just a process coming up. RESTORE_RUN_ID pins the restore
569
+ # to this run: latest.json may name another one (`upload --no-latest`).
570
  token = cfg.siglip2_nvcf_env()["SIGLIP2_TRITON_SERVER_AUTH_TOKEN"]
571
  aws = artifact_env()
572
  result = subprocess.run(
 
574
  "-e", f"SIGLIP2_TRITON_SERVER_AUTH_TOKEN={token}",
575
  "-e", f"SIMREADYCENTRAL_INDEX_STORE_AWS_ACCESS_KEY_ID={aws['AWS_ACCESS_KEY_ID']}",
576
  "-e", f"SIMREADYCENTRAL_INDEX_STORE_AWS_SECRET_ACCESS_KEY={aws['AWS_SECRET_ACCESS_KEY']}",
577
+ "-e", f"RESTORE_RUN_ID={manifest['run_id']}",
578
  tags["hf_space"]],
579
  capture_output=True, text=True, check=True, timeout=60,
580
  )
 
647
  print(" FAIL: hf-space image's download route (hf-storage-api) does not answer")
648
  ok = False
649
 
650
+ # Returned asset URLs are this run's public huggingface.co links
651
+ # (USDSEARCH_URL_MAPPING_RULES), so a stale or missing rule can't ship.
652
+ urls_ok = True
653
+ rules = url_mapping_rules(manifest.get("hf_repos") or [])
654
+ if healthy and manifest.get("storage_backend") == "storage_api" and rules:
655
+ urls_ok, detail = public_url_smoke("http://localhost:17860", [r["public"] for r in rules])
656
+ print(f" returned asset URLs are public https:// links: {urls_ok} ({detail})")
657
+ if not urls_ok:
658
+ print(" FAIL: hf-space image returns asset URLs outside this run's public URL mapping")
659
+ ok = False
660
+
661
+ manifest["hf_space_smoke_passed"] = healthy and graph_ok and download_ok and urls_ok
662
  save_manifest(manifest)
663
  finally:
664
  subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False, timeout=30)
 
679
  ["docker", "run", "--rm", "-d", "-p", "17861:7860", "--gpus", "all",
680
  "-e", f"SIMREADYCENTRAL_INDEX_STORE_AWS_ACCESS_KEY_ID={aws['AWS_ACCESS_KEY_ID']}",
681
  "-e", f"SIMREADYCENTRAL_INDEX_STORE_AWS_SECRET_ACCESS_KEY={aws['AWS_SECRET_ACCESS_KEY']}",
682
+ "-e", f"RESTORE_RUN_ID={manifest['run_id']}",
683
  tags["hf_space_gpu"]],
684
  capture_output=True, text=True, check=True, timeout=60,
685
  )
 
737
  print(" FAIL: hf-space-gpu image's download route (hf-storage-api) does not answer")
738
  ok = False
739
 
740
+ # Returned asset URLs are this run's public huggingface.co links
741
+ # (USDSEARCH_URL_MAPPING_RULES), so a stale or missing rule can't ship.
742
+ urls_ok = True
743
+ rules = url_mapping_rules(manifest.get("hf_repos") or [])
744
+ if healthy and manifest.get("storage_backend") == "storage_api" and rules:
745
+ urls_ok, detail = public_url_smoke("http://localhost:17861", [r["public"] for r in rules])
746
+ print(f" returned asset URLs are public https:// links: {urls_ok} ({detail})")
747
+ if not urls_ok:
748
+ print(" FAIL: hf-space-gpu image returns asset URLs outside this run's public URL mapping")
749
+ ok = False
750
+
751
+ manifest["hf_space_gpu_smoke_passed"] = healthy and embedding_ok and download_ok and urls_ok
752
  save_manifest(manifest)
753
  finally:
754
  subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False, timeout=30)
pipeline/restore.py CHANGED
@@ -8,7 +8,10 @@ cleared first β€” see `clear.py`), or `removed` (cleared and never resubmitted).
8
 
9
  Skipped cleanly on the very first run for a corpus: no `latest.json` yet and
10
  no explicit `RESTORE_RUN_ID` means there is nothing to restore, so the stack
11
- just starts empty, exactly as before this feature existed.
 
 
 
12
 
13
  The OpenSearch load mirrors `docker/opensearch-seed.sh` (same PUT-index +
14
  `_bulk` + `_refresh` sequence, same settings/mappings merge from `bake.py`),
@@ -201,10 +204,13 @@ def restore_neo4j(cfg: Config, manifest: Dict) -> None:
201
  "start", "neo4j", "asset-graph-service"], env=cfg.compose_env())
202
 
203
 
204
- def run(cfg: Config) -> int:
205
- prefix = resolve_baseline_prefix(cfg)
206
  if prefix is None:
207
- print("no previous run found for this corpus β€” starting from an empty index")
 
 
 
208
  if RESTORE_DIR.exists():
209
  shutil.rmtree(RESTORE_DIR)
210
  RESTORE_DIR.mkdir(parents=True)
 
8
 
9
  Skipped cleanly on the very first run for a corpus: no `latest.json` yet and
10
  no explicit `RESTORE_RUN_ID` means there is nothing to restore, so the stack
11
+ just starts empty, exactly as before this feature existed. `--no-restore`
12
+ forces that same empty start for a corpus that does have previous runs β€” a
13
+ full rebuild, e.g. after every asset URL changed (a new pinned revision), where
14
+ a restored baseline would keep each asset's old URL alongside its new one.
15
 
16
  The OpenSearch load mirrors `docker/opensearch-seed.sh` (same PUT-index +
17
  `_bulk` + `_refresh` sequence, same settings/mappings merge from `bake.py`),
 
204
  "start", "neo4j", "asset-graph-service"], env=cfg.compose_env())
205
 
206
 
207
+ def run(cfg: Config, fresh: bool = False) -> int:
208
+ prefix = None if fresh else resolve_baseline_prefix(cfg)
209
  if prefix is None:
210
+ if fresh:
211
+ print("--no-restore β€” starting from an empty index, ignoring any previous run")
212
+ else:
213
+ print("no previous run found for this corpus β€” starting from an empty index")
214
  if RESTORE_DIR.exists():
215
  shutil.rmtree(RESTORE_DIR)
216
  RESTORE_DIR.mkdir(parents=True)
pipeline/run_pipeline.py CHANGED
@@ -115,7 +115,7 @@ def stage_down(cfg: Config, args) -> int:
115
 
116
  def stage_restore(cfg: Config, args) -> int:
117
  from .restore import run
118
- return run(cfg)
119
 
120
 
121
  def stage_clear(cfg: Config, args) -> int:
@@ -158,7 +158,7 @@ def stage_export(cfg: Config, args) -> int:
158
 
159
  def stage_upload(cfg: Config, args) -> int:
160
  from .upload import run
161
- return run(cfg)
162
 
163
 
164
  def stage_bake(cfg: Config, args) -> int:
@@ -343,6 +343,10 @@ def main(argv: List[str] | None = None) -> int:
343
  help="hierarchy: place only Q-codes the tree has never seen; nothing placed moves (what `all` runs)")
344
  parser.add_argument("--no-upload", action="store_true",
345
  help="hierarchy: write .build/hierarchy/ only, publish nothing to the artifact bucket")
 
 
 
 
346
  parser.add_argument("--start", choices=list(STAGES), default=None,
347
  help="all: resume from this stage")
348
  parser.add_argument("--stop", choices=list(STAGES), default=None,
 
115
 
116
  def stage_restore(cfg: Config, args) -> int:
117
  from .restore import run
118
+ return run(cfg, fresh=getattr(args, "no_restore", False))
119
 
120
 
121
  def stage_clear(cfg: Config, args) -> int:
 
158
 
159
  def stage_upload(cfg: Config, args) -> int:
160
  from .upload import run
161
+ return run(cfg, update_latest=not getattr(args, "no_latest", False))
162
 
163
 
164
  def stage_bake(cfg: Config, args) -> int:
 
343
  help="hierarchy: place only Q-codes the tree has never seen; nothing placed moves (what `all` runs)")
344
  parser.add_argument("--no-upload", action="store_true",
345
  help="hierarchy: write .build/hierarchy/ only, publish nothing to the artifact bucket")
346
+ parser.add_argument("--no-restore", action="store_true",
347
+ help="restore/index/all: start from an empty index instead of the previous run (full rebuild)")
348
+ parser.add_argument("--no-latest", action="store_true",
349
+ help="upload: publish the run's artifacts but leave latest.json (what Spaces load) unchanged")
350
  parser.add_argument("--start", choices=list(STAGES), default=None,
351
  help="all: resume from this stage")
352
  parser.add_argument("--stop", choices=list(STAGES), default=None,
pipeline/submission_corpus.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build a `storage_api` corpus file from a SimReady Central submission list.
2
+
3
+ A submission list (e.g. `submissions/nvidia/simready-assets` in
4
+ github.com/NVIDIA-Omniverse/simready-central) is a plain-text file with one
5
+ HF URL per line, each naming one package's marker at a pinned commit:
6
+
7
+ https://huggingface.co/datasets/nvidia/simready-assets/resolve/<sha>/<pkg>/com.nvidia.simready.packaging.json
8
+
9
+ It is the curated, pre-validated set of packages that should be in the index β€”
10
+ the HF dataset's `main` keeps growing past it. This module turns that list into
11
+ the corpus's `include` (one entry USD per package, pinned to the list's sha so
12
+ the index never silently tracks `main`), keeping every other corpus key from
13
+ `--base`:
14
+
15
+ python -m pipeline.submission_corpus --base config/corpus.simready-assets.json
16
+ python -m pipeline.submission_corpus --base config/corpus.simready-assets.json \\
17
+ --from-file /tmp/simready-assets --verify
18
+
19
+ Reading from GitHub shells out to `gh api` (the simready-central repo is
20
+ private), so `gh auth login` must have been done; `--from-file` needs nothing.
21
+ The GitHub commit the list was read from is recorded under the corpus's
22
+ `source` key (ignored by `Corpus.load`) so a corpus can be traced back to it.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import base64
29
+ import json
30
+ import re
31
+ import subprocess
32
+ import sys
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Dict, List, Optional
36
+
37
+ from pipeline import hf_addressing
38
+ from pipeline.corpus import DEFAULT_ASSET_MARKER, Corpus
39
+
40
+ DEFAULT_GITHUB_REPO = "NVIDIA-Omniverse/simready-central"
41
+ DEFAULT_GITHUB_PATH = "submissions/nvidia/simready-assets"
42
+ DEFAULT_GITHUB_REF = "main"
43
+
44
+ # The root USD of a SimReady package β€” the same naming convention the
45
+ # simready-assets corpus's `content_fallback` already encodes.
46
+ DEFAULT_ENTRY_TEMPLATE = "{pkg}/simready_usd/sm_{pkg}_01.usd"
47
+
48
+ _LINE_RE = re.compile(
49
+ r"^https://(?P<host>[^/]+)/datasets/(?P<repo_id>[^/]+/[^/]+)"
50
+ r"/resolve/(?P<revision>[^/]+)/(?P<pkg>[^/]+)/(?P<marker>[^/]+)$"
51
+ )
52
+
53
+
54
+ class SubmissionError(ValueError):
55
+ """The submission list cannot be turned into a corpus as written."""
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Submission:
60
+ host: str
61
+ repo_id: str
62
+ revision: str
63
+ packages: List[str] # in list order
64
+
65
+
66
+ def parse_submission(text: str, marker: str = DEFAULT_ASSET_MARKER) -> Submission:
67
+ """Parse and validate a submission list. Blank lines and `#` comments are
68
+ skipped; every other line must name `marker` directly under a top-level
69
+ package folder, all in one dataset repo at one pinned commit sha."""
70
+ repos = set()
71
+ packages: List[str] = []
72
+ seen: Dict[str, int] = {}
73
+ for lineno, raw in enumerate(text.splitlines(), 1):
74
+ line = raw.strip()
75
+ if not line or line.startswith("#"):
76
+ continue
77
+ m = _LINE_RE.match(line)
78
+ if not m:
79
+ raise SubmissionError(
80
+ f"line {lineno}: {line!r} is not "
81
+ f"https://<host>/datasets/<owner>/<name>/resolve/<sha>/<package>/<marker>"
82
+ )
83
+ if m["marker"] != marker:
84
+ raise SubmissionError(f"line {lineno}: names {m['marker']!r}, expected the package marker {marker!r}")
85
+ if not hf_addressing.looks_like_sha(m["revision"]):
86
+ raise SubmissionError(
87
+ f"line {lineno}: /resolve/{m['revision']}/ is not a full commit sha β€” "
88
+ f"a submission list must pin one"
89
+ )
90
+ repos.add((m["host"], m["repo_id"], m["revision"].lower()))
91
+ if m["pkg"] in seen:
92
+ raise SubmissionError(f"line {lineno}: package {m['pkg']!r} already listed on line {seen[m['pkg']]}")
93
+ seen[m["pkg"]] = lineno
94
+ packages.append(m["pkg"])
95
+ if not packages:
96
+ raise SubmissionError("submission list names no packages")
97
+ if len(repos) != 1:
98
+ raise SubmissionError(
99
+ "submission list spans more than one repo/revision: "
100
+ + ", ".join(f"{host}/{repo}@{rev}" for host, repo, rev in sorted(repos))
101
+ )
102
+ (host, repo_id, revision), = repos
103
+ return Submission(host=host, repo_id=repo_id, revision=revision, packages=packages)
104
+
105
+
106
+ def entry_paths(sub: Submission, entry_template: str = DEFAULT_ENTRY_TEMPLATE) -> List[str]:
107
+ """Repo-relative path of each package's entry USD, sorted."""
108
+ return sorted(entry_template.format(pkg=pkg) for pkg in sub.packages)
109
+
110
+
111
+ def build_corpus(
112
+ base: dict,
113
+ sub: Submission,
114
+ entry_template: str = DEFAULT_ENTRY_TEMPLATE,
115
+ source: Optional[dict] = None,
116
+ ) -> dict:
117
+ """`base` with `include` replaced by the submission's pinned entry USDs.
118
+ Validated through `Corpus.from_dict`, so a result that `enumerate` would
119
+ reject never gets written."""
120
+ if base.get("storage_backend") != "storage_api":
121
+ raise SubmissionError("base corpus must have storage_backend: storage_api")
122
+ doc = dict(base)
123
+ doc["include"] = [
124
+ hf_addressing.build(
125
+ host=sub.host, repo_type="dataset", repo_id=sub.repo_id,
126
+ revision=sub.revision, path=path,
127
+ )
128
+ for path in entry_paths(sub, entry_template)
129
+ ]
130
+ if source is not None:
131
+ doc["source"] = source
132
+ else:
133
+ doc.pop("source", None)
134
+ Corpus.from_dict(doc)
135
+ return doc
136
+
137
+
138
+ def verify_against_repo(sub: Submission, entry_template: str = DEFAULT_ENTRY_TEMPLATE,
139
+ marker: str = DEFAULT_ASSET_MARKER) -> List[str]:
140
+ """Every listed package's marker and entry USD missing from the repo at
141
+ the pinned sha (empty list = all present). One recursive tree listing."""
142
+ from huggingface_hub import HfApi, RepoFile
143
+
144
+ present = {
145
+ entry.path
146
+ for entry in HfApi(endpoint=f"https://{sub.host}").list_repo_tree(
147
+ sub.repo_id, recursive=True, revision=sub.revision, repo_type="dataset",
148
+ )
149
+ if isinstance(entry, RepoFile)
150
+ }
151
+ missing = []
152
+ for pkg in sub.packages:
153
+ for path in (f"{pkg}/{marker}", entry_template.format(pkg=pkg)):
154
+ if path not in present:
155
+ missing.append(path)
156
+ return missing
157
+
158
+
159
+ def _gh_api(endpoint: str, jq: str) -> str:
160
+ try:
161
+ out = subprocess.run(
162
+ ["gh", "api", endpoint, "--jq", jq], check=True, capture_output=True, text=True,
163
+ )
164
+ except FileNotFoundError:
165
+ raise SystemExit("`gh` not found β€” install the GitHub CLI, or pass --from-file")
166
+ except subprocess.CalledProcessError as exc:
167
+ raise SystemExit(f"gh api {endpoint} failed: {exc.stderr.strip()}")
168
+ return out.stdout.strip()
169
+
170
+
171
+ def fetch_from_github(repo: str, path: str, ref: str) -> tuple[str, dict]:
172
+ """The list's text and a `source` record pinning the exact commit it was
173
+ read from (so `--ref main` is still reproducible after the fact)."""
174
+ commit = _gh_api(f"repos/{repo}/commits/{ref}", ".sha")
175
+ content = _gh_api(f"repos/{repo}/contents/{path}?ref={commit}", ".content")
176
+ text = base64.b64decode(content).decode("utf-8")
177
+ return text, {"github": f"https://github.com/{repo}/blob/{commit}/{path}", "commit": commit}
178
+
179
+
180
+ def main(argv: Optional[List[str]] = None) -> int:
181
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
182
+ ap.add_argument("--base", type=Path, required=True,
183
+ help="existing corpus JSON whose non-include keys are kept")
184
+ ap.add_argument("--out", type=Path, help="where to write (default: overwrite --base)")
185
+ src = ap.add_mutually_exclusive_group()
186
+ src.add_argument("--from-file", type=Path, help="read the submission list from a local file")
187
+ src.add_argument("--github-repo", default=DEFAULT_GITHUB_REPO)
188
+ ap.add_argument("--github-path", default=DEFAULT_GITHUB_PATH)
189
+ ap.add_argument("--github-ref", default=DEFAULT_GITHUB_REF,
190
+ help="branch, tag or commit (recorded as the resolved commit sha)")
191
+ ap.add_argument("--entry-template", default=DEFAULT_ENTRY_TEMPLATE,
192
+ help="repo-relative entry USD per package; {pkg} is the package folder")
193
+ ap.add_argument("--verify", action="store_true",
194
+ help="check every package's marker and entry USD exist in the repo at the pinned sha")
195
+ args = ap.parse_args(argv)
196
+
197
+ if args.from_file:
198
+ text, source = args.from_file.read_text(), {"file": str(args.from_file)}
199
+ else:
200
+ text, source = fetch_from_github(args.github_repo, args.github_path, args.github_ref)
201
+ try:
202
+ sub = parse_submission(text)
203
+ doc = build_corpus(json.loads(args.base.read_text()), sub, args.entry_template, source)
204
+ except SubmissionError as exc:
205
+ raise SystemExit(f"submission list: {exc}")
206
+ print(f"{len(sub.packages)} packages from https://{sub.host}/datasets/{sub.repo_id} @ {sub.revision}")
207
+
208
+ if args.verify:
209
+ missing = verify_against_repo(sub, args.entry_template)
210
+ if missing:
211
+ print(f"{len(missing)} paths missing at {sub.revision}:", file=sys.stderr)
212
+ for path in missing[:50]:
213
+ print(f" {path}", file=sys.stderr)
214
+ return 1
215
+ print("verified: every marker and entry USD present at the pinned sha")
216
+
217
+ out = args.out or args.base
218
+ out.write_text(json.dumps(doc, indent=4) + "\n")
219
+ print(f"wrote {out}")
220
+ return 0
221
+
222
+
223
+ if __name__ == "__main__":
224
+ sys.exit(main())
pipeline/upload.py CHANGED
@@ -39,7 +39,7 @@ def prune(cfg: Config, manifest: Dict) -> None:
39
  delete_prefix(cfg, f"{base}/{stale}")
40
 
41
 
42
- def run(cfg: Config) -> int:
43
  if not cfg.get("ARTIFACT_S3_URI").strip():
44
  print(
45
  "ARTIFACT_S3_URI is not set β€” this is the pipeline's one unresolved input.\n"
@@ -63,6 +63,14 @@ def run(cfg: Config) -> int:
63
  return 1
64
  print(f" verified {len(local)} objects")
65
 
 
 
 
 
 
 
 
 
66
  # Written last, and only now: this is what marks the run as current.
67
  pointer = {
68
  "run_id": manifest["run_id"],
 
39
  delete_prefix(cfg, f"{base}/{stale}")
40
 
41
 
42
+ def run(cfg: Config, update_latest: bool = True) -> int:
43
  if not cfg.get("ARTIFACT_S3_URI").strip():
44
  print(
45
  "ARTIFACT_S3_URI is not set β€” this is the pipeline's one unresolved input.\n"
 
63
  return 1
64
  print(f" verified {len(local)} objects")
65
 
66
+ if not update_latest:
67
+ # Every Space without RESTORE_RUN_ID follows latest.json, so leaving
68
+ # it alone publishes the run without switching any live Space to it.
69
+ # Pruning is skipped too: it must never race the run that is current.
70
+ print(f"\npublished {manifest['run_id']} β€” latest.json NOT updated (--no-latest); "
71
+ f"use RESTORE_RUN_ID={manifest['run_id']} to load it")
72
+ return 0
73
+
74
  # Written last, and only now: this is what marks the run as current.
75
  pointer = {
76
  "run_id": manifest["run_id"],