Spaces:
Running
Running
Download scripts/community_model_wizard.py from ThinkcatLab/LiveHouse-TS: direct link, hf CLI and curl.
- Browser
- Download file 9.72 kB
-
https://huggingface.co/spaces/ThinkcatLab/LiveHouse-TS/resolve/main/scripts/community_model_wizard.py
- Command line
-
hf download hf://spaces/ThinkcatLab/LiveHouse-TS/scripts/community_model_wizard.py
-
curl -L -o community_model_wizard.py https://huggingface.co/spaces/ThinkcatLab/LiveHouse-TS/resolve/main/scripts/community_model_wizard.py
9.72 kB
| #!/usr/bin/env python3 | |
| """Initialize and publish a TS-Live community endpoint with fewer manual steps.""" | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import shutil | |
| import subprocess | |
| import sys | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Callable, Sequence | |
| import yaml | |
| REPO_ROOT = Path(__file__).resolve().parents[1] | |
| if str(REPO_ROOT) not in sys.path: | |
| sys.path.insert(0, str(REPO_ROOT)) | |
| from scripts.community_endpoint_protocol import validate_endpoint | |
| from scripts.generate_community_model_metadata import ( | |
| build_metadata, | |
| validate_submission_identity, | |
| ) | |
| TEMPLATE_DIR = REPO_ROOT / "examples" / "community_endpoint" | |
| TEMPLATE_FILES = ( | |
| "app.py", | |
| "Dockerfile", | |
| "requirements.txt", | |
| "README.md", | |
| ".dockerignore", | |
| "compose.yaml", | |
| ) | |
| def initialize_service(output_dir: Path, *, force: bool = False) -> list[Path]: | |
| """Copy the portable endpoint template without clobbering user work.""" | |
| output_dir = output_dir.resolve() | |
| conflicts = [output_dir / name for name in TEMPLATE_FILES if (output_dir / name).exists()] | |
| if conflicts and not force: | |
| names = ", ".join(path.name for path in conflicts) | |
| raise RuntimeError( | |
| f"refusing to overwrite existing files in {output_dir}: {names}; " | |
| "pass --force only if this is intentional" | |
| ) | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| copied = [] | |
| for name in TEMPLATE_FILES: | |
| destination = output_dir / name | |
| shutil.copy2(TEMPLATE_DIR / name, destination) | |
| copied.append(destination) | |
| return copied | |
| def _run_command( | |
| command: Sequence[str], | |
| *, | |
| runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, | |
| ) -> subprocess.CompletedProcess[str]: | |
| try: | |
| result = runner( | |
| list(command), | |
| check=False, | |
| capture_output=True, | |
| text=True, | |
| ) | |
| except FileNotFoundError as exc: | |
| raise RuntimeError( | |
| f"{command[0]!r} was not found; install Tailscale and run " | |
| "`tailscale up` once before publishing" | |
| ) from exc | |
| if result.returncode != 0: | |
| details = "\n".join( | |
| part.strip() for part in (result.stdout, result.stderr) if part.strip() | |
| ) | |
| suffix = f"\n{details}" if details else "" | |
| raise RuntimeError(f"command failed: {' '.join(command)}{suffix}") | |
| return result | |
| def _tailscale_dns_name( | |
| tailscale_bin: str = "tailscale", | |
| *, | |
| runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, | |
| ) -> str: | |
| result = _run_command([tailscale_bin, "status", "--json"], runner=runner) | |
| try: | |
| payload = json.loads(result.stdout) | |
| dns_name = str(payload["Self"]["DNSName"]).strip().rstrip(".") | |
| except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: | |
| raise RuntimeError( | |
| "could not read this device's MagicDNS name from `tailscale status --json`; " | |
| "confirm that Tailscale is connected and MagicDNS is enabled" | |
| ) from exc | |
| if not dns_name or "." not in dns_name: | |
| raise RuntimeError(f"Tailscale returned an invalid DNS name: {dns_name!r}") | |
| return dns_name | |
| def start_tailscale_funnel( | |
| *, | |
| local_port: int, | |
| tailscale_bin: str = "tailscale", | |
| runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, | |
| ) -> str: | |
| """Persistently publish a loopback HTTP service and return its HTTPS URL.""" | |
| if not 1 <= local_port <= 65535: | |
| raise ValueError("local port must be between 1 and 65535") | |
| dns_name = _tailscale_dns_name(tailscale_bin, runner=runner) | |
| target = f"http://127.0.0.1:{local_port}" | |
| _run_command( | |
| [tailscale_bin, "funnel", "--bg", "--https=443", target], | |
| runner=runner, | |
| ) | |
| # A successful status call catches permission/configuration failures that | |
| # some client versions report only after accepting the Funnel command. | |
| _run_command( | |
| [tailscale_bin, "funnel", "status", "--json"], | |
| runner=runner, | |
| ) | |
| return f"https://{dns_name}/forecast" | |
| def publish_submission( | |
| *, | |
| model_id: str, | |
| display_name: str, | |
| code_url: str, | |
| output_dir: Path, | |
| local_port: int = 7860, | |
| endpoint_url: str | None = None, | |
| tailscale_bin: str = "tailscale", | |
| wait_seconds: float = 600.0, | |
| timeout: float = 90.0, | |
| validator: Callable[..., dict[str, object]] = validate_endpoint, | |
| funnel_starter: Callable[..., str] = start_tailscale_funnel, | |
| ) -> dict[str, object]: | |
| """Validate local/public routes and write a complete submission bundle.""" | |
| if not 1 <= local_port <= 65535: | |
| raise ValueError("local port must be between 1 and 65535") | |
| validate_submission_identity( | |
| model_id=model_id, | |
| display_name=display_name, | |
| code_url=code_url, | |
| ) | |
| if endpoint_url is not None: | |
| # Fail fast on an invalid supplied route before making any requests. | |
| build_metadata( | |
| model_id=model_id, | |
| display_name=display_name, | |
| code_url=code_url, | |
| endpoint_url=endpoint_url, | |
| ) | |
| local_url = f"http://127.0.0.1:{local_port}/forecast" | |
| local_receipt = validator( | |
| endpoint_url=local_url, | |
| model_id=model_id, | |
| timeout=timeout, | |
| require_https=False, | |
| ) | |
| provider = "existing_https" | |
| if endpoint_url is None: | |
| endpoint_url = funnel_starter( | |
| local_port=local_port, | |
| tailscale_bin=tailscale_bin, | |
| ) | |
| provider = "tailscale_funnel" | |
| # The endpoint was unknown during the preflight when Funnel was selected, | |
| # so validate the complete metadata before the public readiness wait. | |
| metadata = build_metadata( | |
| model_id=model_id, | |
| display_name=display_name, | |
| code_url=code_url, | |
| endpoint_url=endpoint_url, | |
| ) | |
| public_receipt = validator( | |
| endpoint_url=endpoint_url, | |
| model_id=model_id, | |
| timeout=timeout, | |
| wait_seconds=wait_seconds, | |
| require_https=True, | |
| ) | |
| receipt = { | |
| "status": "ok", | |
| "generated_at_utc": datetime.now(timezone.utc).isoformat(), | |
| "provider": provider, | |
| "endpoint_url": endpoint_url, | |
| "local_validation": local_receipt, | |
| "public_validation": public_receipt, | |
| } | |
| output_dir = output_dir.resolve() | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| metadata_path = output_dir / "community_model.yaml" | |
| receipt_path = output_dir / "validator_receipt.json" | |
| metadata_path.write_text( | |
| yaml.safe_dump(metadata, sort_keys=False, allow_unicode=True), | |
| encoding="utf-8", | |
| ) | |
| receipt_path.write_text( | |
| json.dumps(receipt, indent=2, ensure_ascii=False) + "\n", | |
| encoding="utf-8", | |
| ) | |
| return { | |
| "status": "ok", | |
| "endpoint_url": endpoint_url, | |
| "metadata_path": str(metadata_path), | |
| "receipt_path": str(receipt_path), | |
| } | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| subparsers = parser.add_subparsers(dest="command", required=True) | |
| init_parser = subparsers.add_parser( | |
| "init", help="copy the endpoint template into an editable service directory" | |
| ) | |
| init_parser.add_argument("--output-dir", type=Path, default=Path("forecast-service")) | |
| init_parser.add_argument("--force", action="store_true") | |
| publish_parser = subparsers.add_parser( | |
| "publish", | |
| help="validate locally, publish through Funnel, and generate submission files", | |
| ) | |
| publish_parser.add_argument("--model-id", required=True) | |
| publish_parser.add_argument("--display-name", required=True) | |
| publish_parser.add_argument("--code-url", required=True) | |
| publish_parser.add_argument("--local-port", type=int, default=7860) | |
| publish_parser.add_argument( | |
| "--endpoint-url", | |
| help="use an existing public HTTPS /forecast URL instead of Tailscale Funnel", | |
| ) | |
| publish_parser.add_argument("--tailscale-bin", default="tailscale") | |
| publish_parser.add_argument("--wait-seconds", type=float, default=600.0) | |
| publish_parser.add_argument("--timeout", type=float, default=90.0) | |
| publish_parser.add_argument( | |
| "--output-dir", type=Path, default=Path("community-submission") | |
| ) | |
| return parser.parse_args() | |
| def main() -> int: | |
| args = parse_args() | |
| if args.command == "init": | |
| copied = initialize_service(args.output_dir, force=args.force) | |
| print( | |
| json.dumps( | |
| { | |
| "status": "ok", | |
| "service_dir": str(args.output_dir.resolve()), | |
| "copied": [str(path) for path in copied], | |
| "next": ( | |
| "Replace forecast_one, then run: docker compose -f " | |
| f'"{args.output_dir.resolve() / "compose.yaml"}" ' | |
| "up -d --build" | |
| ), | |
| }, | |
| indent=2, | |
| ) | |
| ) | |
| return 0 | |
| result = publish_submission( | |
| model_id=args.model_id, | |
| display_name=args.display_name, | |
| code_url=args.code_url, | |
| output_dir=args.output_dir, | |
| local_port=args.local_port, | |
| endpoint_url=args.endpoint_url, | |
| tailscale_bin=args.tailscale_bin, | |
| wait_seconds=args.wait_seconds, | |
| timeout=args.timeout, | |
| ) | |
| print(json.dumps(result, indent=2)) | |
| return 0 | |
| if __name__ == "__main__": | |
| try: | |
| raise SystemExit(main()) | |
| except (RuntimeError, ValueError) as exc: | |
| raise SystemExit(f"community model setup failed: {exc}") from exc | |