"""Publish the environment as a Hugging Face Space that serves the full corpus. The Space image holds code and the corpus manifest only. The catalog is copied from the bucket at startup, and each design's pixels are read from it the first time an episode uses it: over HTTP by default, or with --transport mount from the bucket attached read-only at /corpus. python scripts/deploy_space.py --space FineEnvs/DesignGym \\ --manifest corpus-manifest.json --private Refuses to deploy unless the manifest's catalog is published in the bucket with the exact size recorded in the manifest. """ from __future__ import annotations import argparse import json import shutil import sys import tempfile from pathlib import Path HERE = Path(__file__).resolve().parents[1] IGNORE = shutil.ignore_patterns(".venv", "__pycache__", ".pytest_cache", ".ruff_cache", "*.egg-info", "tests", "artifacts") def main(argv=None) -> int: from huggingface_hub import HfApi, Volume ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--space", required=True, help="e.g. FineEnvs/DesignGym") ap.add_argument("--manifest", default=str(HERE / "corpus-manifest.json")) ap.add_argument("--hardware", default="cpu-upgrade", help="Space hardware (GUI episodes run Chromium)") ap.add_argument("--private", action="store_true") ap.add_argument("--max-envs", type=int, default=32, help="concurrent environment sessions per worker") ap.add_argument("--workers", type=int, default=4, help="server processes behind the Space's one URL") ap.add_argument("--reward", default="3", help="DESIGN_CANVAS_REWARD (3 = reward v3, as the evals use)") ap.add_argument("--daytona-desktops", type=int, default=0, help="run the real apps on Daytona with this many desktops (0 = no real apps); the key and URL " "are copied from DAYTONA_API_KEY / DAYTONA_API_URL into the Space's secrets") ap.add_argument("--transport", choices=["http", "mount"], default="http", help="read designs over HTTP (default) or from the bucket mounted at /corpus; the mount returned " "I/O errors under the workers' random reads (2026-10-05)") args = ap.parse_args(argv) manifest = json.loads(Path(args.manifest).read_text()) bucket = manifest["bucket_id"] api = HfApi() catalog = manifest["catalog"] found = {r.path: r.size for r in api.get_bucket_paths_info(bucket, [catalog["path"]])} if found.get(catalog["path"]) != catalog["size"]: sys.exit(f"publish the index first: {catalog['path']} is not in {bucket} at the recorded size") with tempfile.TemporaryDirectory(prefix="design-canvas-space-") as tmp: staging = Path(tmp) shutil.copytree(HERE, staging, dirs_exist_ok=True, ignore=IGNORE) shutil.copyfile(args.manifest, staging / "corpus-manifest.json") with (staging / "README.md").open("a") as card: n = sum(manifest["splits"].values()) card.write("\n## Served corpus\n\n") card.write(f"{n:,} Crello designs ({', '.join(f'{k} {v:,}' for k, v in manifest['splits'].items())}), " f"read on demand from the [{bucket}](https://huggingface.co/buckets/{bucket}) bucket " f"mounted at `/corpus`. Snapshot `{manifest['snapshot_id'][:16]}`.\n") api.create_repo(args.space, repo_type="space", space_sdk="docker", private=args.private, exist_ok=True, space_hardware=args.hardware) commit = api.upload_folder(repo_id=args.space, repo_type="space", folder_path=staging, commit_message=f"Serve Crello corpus {manifest['snapshot_id'][:12]} " "from the mounted bucket") runtime = api.space_info(args.space).runtime volumes = [v for v in (runtime.volumes or []) if v.mount_path != "/corpus"] if runtime else [] if args.transport == "mount": volumes.append(Volume(type="bucket", source=bucket, mount_path="/corpus", read_only=True)) if volumes: api.set_space_volumes(args.space, volumes=volumes) elif runtime and runtime.volumes: api.delete_space_volumes(args.space) # the Hub refuses an empty list current = api.get_space_variables(args.space) if args.transport == "http" and "DESIGN_CANVAS_SOURCE_ROOT" in current: api.delete_space_variable(args.space, "DESIGN_CANVAS_SOURCE_ROOT") for key, value in {"MAX_CONCURRENT_ENVS": str(args.max_envs), "DESIGN_CANVAS_WORKERS": str(args.workers), "DESIGN_CANVAS_REWARD": args.reward, **({"DESIGN_CANVAS_SOURCE_ROOT": "/corpus"} if args.transport == "mount" else {}), "DESIGN_CANVAS_PUBLIC_URL": f"https://{args.space.replace('/', '-').lower()}.hf.space"}.items(): if key not in current or current[key].value != value: api.add_space_variable(args.space, key, value) if args.daytona_desktops > 0: # the Space's own pool, so it never takes a local server's desktops import os for key, value in {"DESIGN_CANVAS_APPS": "daytona", "DESIGN_CANVAS_DAYTONA_POOL": "space", "DESIGN_CANVAS_DAYTONA_DESKTOPS": str(args.daytona_desktops)}.items(): if key not in current or current[key].value != value: api.add_space_variable(args.space, key, value) for key in ("DAYTONA_API_KEY", "DAYTONA_API_URL"): if os.environ.get(key): api.add_space_secret(args.space, key, os.environ[key]) elif "DESIGN_CANVAS_APPS" in current: api.delete_space_variable(args.space, "DESIGN_CANVAS_APPS") print(json.dumps({"space": args.space, "commit": commit.oid, "snapshot_id": manifest["snapshot_id"], "bucket": bucket, "transport": args.transport, "daytona_desktops": args.daytona_desktops, "url": f"https://huggingface.co/spaces/{args.space}"}, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())