Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """Create and deploy the img2threejs Docker Space with the local ``hf`` CLI. | |
| The uploader is deliberately allowlist-based. It stages only the files in | |
| ``deployment_manifest()`` and performs exactly one ``hf upload`` from that | |
| sanitized directory. Authentication comes from the active ``hf auth`` store; | |
| ``HF_TOKEN`` is optional. | |
| Examples: | |
| python3 scripts/deploy_space.py --dry-run | |
| python3 scripts/deploy_space.py | |
| python3 scripts/deploy_space.py --no-secrets | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import shlex | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from pathlib import Path | |
| REPO_ROOT = Path(__file__).resolve().parents[1] | |
| DEFAULT_SPACE_ID = "Mike0021/img2threejs" | |
| # Root files and source trees intentionally published to the Space repository. | |
| # The Docker runtime has a narrower COPY allowlist in Dockerfile. | |
| MANIFEST_ROOT_FILES = ( | |
| ".dockerignore", | |
| ".gitignore", | |
| "CHANGELOG.md", | |
| "CONTRIBUTING.md", | |
| "Dockerfile", | |
| "LICENSE", | |
| "README.md", | |
| "ROADMAP.md", | |
| "SKILL.md", | |
| "UPSTREAM_REVISION", | |
| "package-lock.json", | |
| "package.json", | |
| "pytest.ini", | |
| "requirements.txt", | |
| ) | |
| MANIFEST_TREES = ( | |
| "app", "assets", "docs", "forge", "grimoire", "scripts", "tests", | |
| ) | |
| REQUIRED_PATHS = ( | |
| "README.md", | |
| "Dockerfile", | |
| "requirements.txt", | |
| "package.json", | |
| "package-lock.json", | |
| "app/main.py", | |
| "app/static/index.html", | |
| "UPSTREAM_REVISION", | |
| "forge/stage2_spec/validate_sculpt_spec.py", | |
| "forge/stage3_build/generate_threejs_factory.py", | |
| "scripts/build_fixture_factory.py", | |
| "scripts/node_smoke.mjs", | |
| "tests/fixtures/canned_spec.json", | |
| ) | |
| EXCLUDED_DIR_NAMES = { | |
| ".git", | |
| ".pytest_cache", | |
| ".venv", | |
| ".workflow", | |
| "__pycache__", | |
| "htmlcov", | |
| "node_modules", | |
| "upstream-src", | |
| "verification", | |
| } | |
| EXCLUDED_EXACT = {"tests/fixtures/factory_fixture.ts"} | |
| # Destination secret -> accepted environment aliases, in priority order. | |
| SECRET_SOURCES = { | |
| "LLM_API_KEY": ("LLM_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"), | |
| "LLM_BASE_URL": ("LLM_BASE_URL", "ANTHROPIC_BASE_URL"), | |
| "LLM_MODEL": ("LLM_MODEL", "ANTHROPIC_MODEL"), | |
| "LLM_API_STYLE": ("LLM_API_STYLE",), | |
| "LLM_MAX_TOKENS": ("LLM_MAX_TOKENS",), | |
| "LLM_TIMEOUT_S": ("LLM_TIMEOUT_S",), | |
| "LLM_MAX_RETRIES": ("LLM_MAX_RETRIES",), | |
| "LLM_REFERER": ("LLM_REFERER",), | |
| "LLM_TITLE": ("LLM_TITLE",), | |
| } | |
| class ManifestError(RuntimeError): | |
| """The local source tree cannot be safely staged for deployment.""" | |
| def _excluded(relative: Path) -> bool: | |
| posix = relative.as_posix() | |
| name = relative.name | |
| if any(part in EXCLUDED_DIR_NAMES for part in relative.parts): | |
| return True | |
| if posix in EXCLUDED_EXACT: | |
| return True | |
| if name in {".DS_Store", ".coverage", ".env"} or name.startswith(".env."): | |
| return True | |
| if name.startswith("rollout") and name.endswith(".jsonl"): | |
| return True | |
| if name.endswith((".pyc", ".pyo", ".log")): | |
| return True | |
| return False | |
| def deployment_manifest(root: Path = REPO_ROOT) -> tuple[str, ...]: | |
| """Return the sorted, repo-relative deployment file allowlist. | |
| Symlinks are rejected rather than dereferenced so a local workspace link | |
| cannot copy data from outside the repository into a public Space. | |
| """ | |
| root = root.resolve() | |
| missing = [path for path in REQUIRED_PATHS if not (root / path).is_file()] | |
| if missing: | |
| raise ManifestError("required deployment files missing: " + ", ".join(missing)) | |
| selected: set[str] = set() | |
| for relative_text in MANIFEST_ROOT_FILES: | |
| path = root / relative_text | |
| if not path.exists(): | |
| continue | |
| if path.is_symlink(): | |
| raise ManifestError(f"deployment file is a symlink: {relative_text}") | |
| if not path.is_file(): | |
| raise ManifestError(f"deployment path is not a file: {relative_text}") | |
| selected.add(relative_text) | |
| for tree_name in MANIFEST_TREES: | |
| tree = root / tree_name | |
| if not tree.is_dir(): | |
| raise ManifestError(f"deployment tree missing: {tree_name}") | |
| for path in tree.rglob("*"): | |
| relative = path.relative_to(root) | |
| if path.is_symlink(): | |
| raise ManifestError(f"deployment tree contains symlink: {relative.as_posix()}") | |
| if path.is_file() and not _excluded(relative): | |
| selected.add(relative.as_posix()) | |
| return tuple(sorted(selected)) | |
| def _stage_manifest(root: Path, manifest: tuple[str, ...], destination: Path) -> None: | |
| for relative_text in manifest: | |
| source = root / relative_text | |
| target = destination / relative_text | |
| target.parent.mkdir(parents=True, exist_ok=True) | |
| shutil.copy2(source, target) | |
| def _run(argv: list[str]) -> None: | |
| # Commands contain paths and secret *names* only; secret values are sent | |
| # through a mode-0600 temporary file and never appear in logs or argv. | |
| print("->", shlex.join(argv), flush=True) | |
| subprocess.run(argv, check=True) | |
| def _environment_secrets() -> dict[str, str]: | |
| values: dict[str, str] = {} | |
| for destination, candidates in SECRET_SOURCES.items(): | |
| value = next( | |
| (os.environ[name] for name in candidates if os.environ.get(name, "").strip()), | |
| None, | |
| ) | |
| if value is None: | |
| continue | |
| if "\n" in value or "\r" in value or "\0" in value: | |
| raise ManifestError(f"secret {destination} contains a forbidden control character") | |
| values[destination] = value | |
| return values | |
| def _set_secrets(space_id: str, values: dict[str, str]) -> None: | |
| if not values: | |
| print( | |
| "-> no supported LLM secret variables found; deployment will use " | |
| "the honest llm_not_configured path", | |
| file=sys.stderr, | |
| ) | |
| return | |
| file_descriptor, raw_path = tempfile.mkstemp( | |
| prefix="img2threejs-secrets-", suffix=".env" | |
| ) | |
| secret_path = Path(raw_path) | |
| try: | |
| os.fchmod(file_descriptor, 0o600) | |
| with os.fdopen(file_descriptor, "w", encoding="utf-8") as stream: | |
| for name in sorted(values): | |
| # JSON double-quoted strings are accepted dotenv values and | |
| # preserve spaces/# characters without exposing the value. | |
| stream.write(f"{name}={json.dumps(values[name])}\n") | |
| print("-> setting Space Secrets: " + ", ".join(sorted(values)), flush=True) | |
| _run(["hf", "spaces", "secrets", "add", space_id, | |
| "--secrets-file", str(secret_path)]) | |
| finally: | |
| secret_path.unlink(missing_ok=True) | |
| def _print_manifest(root: Path, manifest: tuple[str, ...]) -> None: | |
| total = sum((root / item).stat().st_size for item in manifest) | |
| print(f"deployment manifest: {len(manifest)} files, {total} bytes") | |
| for item in manifest: | |
| print(item) | |
| def main(argv: list[str] | None = None) -> int: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--space-id", default=DEFAULT_SPACE_ID) | |
| parser.add_argument("--dry-run", action="store_true", | |
| help="print the exact allowlist; do not authenticate or mutate the Hub") | |
| parser.add_argument("--no-secrets", action="store_true", | |
| help="do not set any environment-derived Space Secrets") | |
| parser.add_argument("--private", action="store_true", | |
| help="make a newly-created Space private") | |
| parser.add_argument( | |
| "--commit-message", | |
| default="Deploy verified img2threejs Docker Space", | |
| ) | |
| args = parser.parse_args(argv) | |
| try: | |
| manifest = deployment_manifest(REPO_ROOT) | |
| _print_manifest(REPO_ROOT, manifest) | |
| if args.dry_run: | |
| return 0 | |
| # Uses the current hf CLI credential store. No HF_TOKEN is required. | |
| _run(["hf", "auth", "whoami", "--format", "json"]) | |
| create = [ | |
| "hf", "repos", "create", args.space_id, | |
| "--type", "space", "--sdk", "docker", "--flavor", "cpu-basic", | |
| "--exist-ok", "--private" if args.private else "--public", | |
| ] | |
| _run(create) | |
| if not args.no_secrets: | |
| _set_secrets(args.space_id, _environment_secrets()) | |
| with tempfile.TemporaryDirectory(prefix="img2threejs-upload-") as temp_dir: | |
| stage = Path(temp_dir) | |
| _stage_manifest(REPO_ROOT, manifest, stage) | |
| _run([ | |
| "hf", "upload", args.space_id, str(stage), ".", | |
| "--type", "space", "--commit-message", args.commit_message, | |
| ]) | |
| slug = args.space_id.lower().replace("/", "-").replace("_", "-") | |
| print(f"-> Space page: https://huggingface.co/spaces/{args.space_id}") | |
| print(f"-> App URL: https://{slug}.hf.space") | |
| return 0 | |
| except (ManifestError, OSError, subprocess.CalledProcessError) as exc: | |
| print(f"deployment failed: {exc}", file=sys.stderr) | |
| if isinstance(exc, subprocess.CalledProcessError) and exc.returncode: | |
| return int(exc.returncode) | |
| return 2 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |