Files
gnommo/gnommo/pull.py
T
2026-03-26 10:46:05 +01:00

234 lines
8.3 KiB
Python

"""Pull project metadata from gnommoweb server.
Usage:
gnommo pull -p video1 # pull parent video project
gnommo pull -p short_pixelated_universe # pull a short project
gnommo pull -p myproject --force # force pull, overwrite local
For a parent project: updates name, description, and the shorts index
(list of slugs) in project.json.
For a short project: updates title, hook, platform_targets, resolution,
fps, duration_seconds. Preserves local script path reference.
Conflict detection:
- If local project.json mtime > last_pushed_at → local has unpushed changes
→ warn and abort unless --force
Configuration (from .env or environment):
GNOMMOWEB_URL Base URL (e.g. http://localhost:3001)
GNOMMOWEB_API_KEY Bearer token (CONTENT_API_KEY)
"""
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
try:
import requests
except ImportError:
print(
"Error: 'requests' package is required. Run: pip install requests",
file=sys.stderr,
)
sys.exit(1)
SYNC_FILE_LOCAL = ".gnommo_sync.json"
SYNC_FILE_PROD = ".gnommo_sync.prod.json"
def _sync_file(prod: bool) -> str:
return SYNC_FILE_PROD if prod else SYNC_FILE_LOCAL
def _load_env_file():
env_path = Path(__file__).parent.parent / ".env"
if not env_path.exists():
return
with open(env_path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key not in os.environ:
os.environ[key] = value
def _read_sync(project_path: Path, prod: bool = False) -> dict:
sync_file = project_path / _sync_file(prod)
if sync_file.exists():
with open(sync_file) as f:
return json.load(f)
return {}
def _write_sync(project_path: Path, data: dict, prod: bool = False):
with open(project_path / _sync_file(prod), "w") as f:
json.dump(data, f, indent=2)
def _parse_ts(ts_str) -> datetime | None:
if not ts_str:
return None
try:
return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
except ValueError:
return None
def cmd_pull(
project_path: Path, verbose: bool = False, force: bool = False, prod: bool = False
) -> int:
_load_env_file()
if prod:
api_url = os.environ.get("GNOMMOWEB_PROD_URL", "").rstrip("/")
api_key = os.environ.get("GNOMMOWEB_PROD_API_KEY", "")
if not api_url:
print("Error: GNOMMOWEB_PROD_URL is not set.", file=sys.stderr)
return 1
if not api_key:
print("Error: GNOMMOWEB_PROD_API_KEY is not set.", file=sys.stderr)
return 1
else:
api_url = os.environ.get("GNOMMOWEB_URL", "").rstrip("/")
api_key = os.environ.get("GNOMMOWEB_API_KEY", "")
if not api_url:
print("Error: GNOMMOWEB_URL is not set.", file=sys.stderr)
return 1
if not api_key:
print("Error: GNOMMOWEB_API_KEY is not set.", file=sys.stderr)
return 1
if verbose:
target = "production" if prod else "local"
print(f"{target}: {api_url}")
project_file = project_path / "project.json"
if not project_file.exists():
print(f"Error: {project_file} not found", file=sys.stderr)
return 1
with open(project_file) as f:
local_project = json.load(f)
project_id = local_project.get("id")
if not project_id:
print("Error: project.json missing 'id'.", file=sys.stderr)
return 1
# ── Conflict check ────────────────────────────────────────────────────────
if not force:
sync = _read_sync(project_path, prod)
last_pushed_at = _parse_ts(sync.get("last_pushed_at"))
local_mtime = datetime.fromtimestamp(
project_file.stat().st_mtime, tz=timezone.utc
)
if last_pushed_at and local_mtime > last_pushed_at:
print(
f"⚠ project.json has local changes since last push "
f"({local_mtime.strftime('%Y-%m-%d %H:%M')} > "
f"{last_pushed_at.strftime('%Y-%m-%d %H:%M')}).",
file=sys.stderr,
)
print(
" Push first with `gnommo push -p` or use `gnommo pull -p --force`.",
file=sys.stderr,
)
return 1
# ── Fetch from server ─────────────────────────────────────────────────────
if verbose:
print(f"Pulling {project_id} from {api_url}")
try:
r = requests.get(
f"{api_url}/api/projects/{project_id}",
headers={"Authorization": f"Bearer {api_key}"},
timeout=30,
)
except requests.exceptions.ConnectionError:
print(f"✗ Could not connect to {api_url}")
return 1
if not r.ok:
if r.status_code == 404:
print(f"✗ Project '{project_id}' not found on server. Push it first.")
else:
try:
body = r.json()
except Exception:
body = r.text[:500]
print(f"✗ Server returned {r.status_code}: {body}")
return 1
server = r.json()
server_updated_at = server.get("updated_at")
project_type = server.get("type")
# ── Merge into project.json ───────────────────────────────────────────────
if project_type == "parent":
_merge_parent(local_project, server, verbose)
count = len(server.get("shorts", []))
print(f"✓ Pulled {project_id} (parent video) — {count} short(s) in index")
elif project_type == "short":
_merge_short(local_project, server, verbose)
print(f"✓ Pulled {project_id} (short) — [{server.get('status')}]")
else:
print(f"Error: unexpected project type: {project_type}", file=sys.stderr)
return 1
# ── Write back ────────────────────────────────────────────────────────────
with open(project_file, "w") as f:
json.dump(local_project, f, indent=2, ensure_ascii=False)
f.write("\n")
now_iso = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
existing_sync = _read_sync(project_path, prod)
_write_sync(
project_path,
{
**existing_sync,
"last_pulled_at": now_iso,
"server_updated_at": server_updated_at,
"last_pushed_at": existing_sync.get("last_pushed_at"),
},
prod,
)
return 0
def _merge_parent(local: dict, server: dict, verbose: bool):
"""Update parent project.json: name, description, shorts index (slugs)."""
local["name"] = server.get("title", local.get("name"))
local["description"] = server.get("description") or local.get("description")
# shorts is a list of slugs — update from server's shorts list
server_shorts = server.get("shorts", [])
local["shorts"] = [s["project_id"] for s in server_shorts]
if verbose:
print(f" shorts index: {local['shorts']}")
def _merge_short(local: dict, server: dict, verbose: bool):
"""Update short project.json: name, hook, platform_targets, resolution, fps, duration."""
local["name"] = server.get("title", local.get("name"))
if server.get("hook"):
local["hook"] = server["hook"]
if server.get("platform_targets"):
local["platform_targets"] = server["platform_targets"]
if server.get("resolution"):
local["resolution"] = server["resolution"]
if server.get("fps"):
local["fps"] = server["fps"]
if server.get("duration_seconds"):
local["duration_seconds"] = server["duration_seconds"]
if server.get("parent_project_id"):
local["parent_project"] = server["parent_project_id"]
# Never overwrite local script path — that stays local