#!/usr/bin/env python3 """Deploy do dashboard CCO Apoena como HF Space (Docker, público). Cria/atualiza o Space, sobe o código + DB embutido + artefatos, e configura os secrets de runtime (credenciais F2M e LLM) a partir do .env.local. Público por default (paridade com o gêmeo cco-mina-almas; Safari não abre Space privado por bloqueio de cookie cross-site). Use --private p/ restringir. Uso: ./scripts/deploy_hf.py [--private] """ from __future__ import annotations import argparse import os from pathlib import Path ROOT = Path(__file__).resolve().parent.parent SPACE_NAME = "cco-apn" # Arquivos/dirs a NÃO subir para o Space. # .gitignore é EXCLUÍDO de propósito: se for pro Space, tanto huggingface_hub # quanto git passam a honrá-lo e os *.duckdb embutidos somem do build. IGNORE = [ ".git/*", ".git", ".gitignore", ".env.local", ".session_secret", "**/__pycache__/*", "**/__pycache__", "*.pyc", ".claude/*", ".claude", "*.bak", "*.bak*", "cco_state.duckdb", "f2m_2026.duckdb", "docs/PORTABILIDADE_CCO.json", # mapa interno, desnecessário em runtime ".ruff_cache/*", ".pytest_cache/*", ".venv/*", ] # Secrets de runtime (lidos do .env.local). F2M_REPORT_BASE já está no Dockerfile. SECRET_KEYS = ["F2M_EMAIL", "F2M_PASSWORD", "OPENROUTER_KEY", "NVIDIA_API_KEY"] # Variáveis públicas do Space (não-sensíveis). Poll a cada 120s = mesma cadência # do gêmeo cco-mina-almas (que sobrescreve o default 300 do Dockerfile via variável). SPACE_VARIABLES = {"F2M_POLL_INTERVAL": "120"} def load_env_local(path: Path) -> None: if not path.exists(): return for line in path.read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) def _push_dbs_via_git(token: str, user: str, repo_id: str) -> None: """Sobe os *.duckdb embutidos via git-lfs nativo (robusto p/ arquivos >10MB).""" import shutil import subprocess import tempfile dbs = [fn for fn in ("f2m_local.duckdb", "viagens_baseline.duckdb") if (ROOT / fn).exists()] if not dbs: print(" nenhum *.duckdb local — pule (rode bootstrap_extract.py / build_viagens_baseline.py)") return url = f"https://{user}:{token}@huggingface.co/spaces/{repo_id}" with tempfile.TemporaryDirectory() as tmp: clone = Path(tmp) / "space" subprocess.run(["git", "lfs", "install", "--skip-repo"], check=True, capture_output=True) subprocess.run(["git", "clone", "--depth", "1", url, str(clone)], check=True, capture_output=True) # .gitattributes: garante LFS p/ duckdb; remove .gitignore (não pode ir pro Space) ga = clone / ".gitattributes" txt = ga.read_text() if ga.exists() else "" if "duckdb" not in txt: ga.write_text(txt.rstrip() + "\n*.duckdb filter=lfs diff=lfs merge=lfs -text\n") (clone / ".gitignore").unlink(missing_ok=True) for fn in dbs: shutil.copy2(ROOT / fn, clone / fn) g = ["git", "-C", str(clone)] subprocess.run([*g, "rm", "-q", "--cached", "--ignore-unmatch", ".gitignore"], capture_output=True) subprocess.run([*g, "add", "-f", ".gitattributes", *dbs], check=True) subprocess.run([*g, "-c", "user.email=deploy@local", "-c", "user.name=deploy", "commit", "-q", "-m", "DBs embutidos (git-lfs)"], capture_output=True) subprocess.run([*g, "push", "origin", "HEAD:main"], check=True, capture_output=True) for fn in dbs: print(f" {fn}: {(ROOT / fn).stat().st_size/1e6:.1f} MB (LFS)") def main() -> int: p = argparse.ArgumentParser() p.add_argument("--private", action="store_true", help="cria Space privado (default: público)") args = p.parse_args() load_env_local(ROOT / ".env.local") token = os.environ.get("HF_TOKEN", "") if not token: print("ERRO: HF_TOKEN ausente no .env.local") return 1 from huggingface_hub import HfApi api = HfApi(token=token) user = api.whoami()["name"] repo_id = f"{user}/{SPACE_NAME}" private = args.private print(f"== Space: {repo_id} (private={private}, sdk=docker) ==") api.create_repo( repo_id=repo_id, repo_type="space", space_sdk="docker", private=private, exist_ok=True, ) print("== Configurando secrets de runtime ==") for k in SECRET_KEYS: v = os.environ.get(k, "") if v: api.add_space_secret(repo_id=repo_id, key=k, value=v) print(f" secret {k}: set ({len(v)} chars)") else: print(f" secret {k}: AUSENTE no .env.local — pulado") print("== Configurando variáveis públicas do Space ==") for k, v in SPACE_VARIABLES.items(): api.add_space_variable(repo_id=repo_id, key=k, value=v) print(f" variable {k}={v}") print("== Upload código + artefatos (upload_folder honra .gitignore)... ==") api.upload_folder( folder_path=str(ROOT), repo_id=repo_id, repo_type="space", ignore_patterns=IGNORE, commit_message="Deploy CCO Apoena (réplica do cco)", ) # Os DuckDB embutidos (jan-jun) sobem via git + git-lfs nativo: o # huggingface_hub 1.9.0 cria o commit mas não persiste o blob LFS (>10MB). print("== Upload DBs embutidos (git-lfs)... ==") _push_dbs_via_git(token, user, repo_id) print(f"\nOK — https://huggingface.co/spaces/{repo_id}") print(f"App: https://{user}-{SPACE_NAME}.hf.space") return 0 if __name__ == "__main__": raise SystemExit(main())