Spaces:
Running on RTX PRO 6000
Running on RTX PRO 6000
Deploy simready-assets run 20261005-ee37df6 (GPU variant) (part 2)
Browse files- pipeline/bake.py +104 -3
- pipeline/restore.py +10 -4
- pipeline/run_pipeline.py +6 -2
- pipeline/submission_corpus.py +224 -0
- pipeline/upload.py +9 -1
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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"],
|