img2threejs / scripts /deploy_space.py
Mike0021's picture
Add persistent community gallery and v1.3 previews
37e3d5a verified
Raw
History Blame Contribute Delete
9.26 kB
#!/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())