"""Construit une version livrable du Space : manifest.json à jour, puis un zip des fichiers de la version. python scripts/build_release.py # manifest + dist/unique-v3.9.zip python scripts/build_release.py --check # vérifie seulement que l'arbre correspond au manifest Le manifest liste les fichiers d'exécution (ceux que le panneau du Space vérifie au démarrage, et que `unique_ext.updater` contrôle avant d'installer un zip) : ceux de la v3.8 plus tout `unique_ext/`. Le zip contient le manifest, ces fichiers, et les tests et scripts associés. Il s'envoie tel quel dans le Space, à la main (dossiers dans Files → Upload files) ou, dès la v3.9 en place, en un seul fichier avec UNIQUE_EXT_INSTALL=. """ from __future__ import annotations import argparse import hashlib import json import sys import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] MANIFEST = "mouh/learning/manifest.json" CORE = ["app.py", "requirements.txt", "mouh/service.py", "mouh/verification/loop.py", "mouh/learning/__init__.py", "mouh/learning/prompts.py", "mouh/learning/records.py", "mouh/learning/recorder.py", "mouh/learning/memory.py", "mouh/learning/unique_generator.py", "jobs/train_unique.py", "mouh/learning/local_store.py", "mouh/learning/local_ingest.py", "mouh/learning/local_trainer.py", "mouh/learning/dashboard.py", "mouh/geometry/__init__.py", "mouh/geometry/mesh.py", "mouh/geometry/api.py", "mouh/geometry/sync.py"] # Frontend VORTEX / SYMBIOZA 3D : tout l'arbre web/symbioza en fichiers texte (page, CSS, modules, three.js vendorisé). # Seuls les suffixes texte sont hachés : l'empreinte est calculée en UTF-8, un binaire n'y entrerait pas. WEB_DIR = "web/symbioza" WEB_SUFFIXES = (".html", ".css", ".js", ".svg", ".json", ".txt") # v4.1 : fichiers retirés (mode Jobs, maître, alimentation, export GGUF, installation à la volée, v3.14) ; l'updater # les supprime du Space au moment de l'installation, et les rétablit en cas de retour arrière. REMOVED = ["mouh/learning/autopilot.py", "jobs/teacher_tasks.py", "jobs/ingest_tasks.py", "unique_ext/autopilot.py", "unique_ext/install.py", "unique_ext/backends/gguf.py", "unique_ext/jobs/__init__.py", "unique_ext/jobs/export_gguf.py", "unique_ext/jobs/eval_generators.py", "unique_ext/remote/__init__.py", "unique_ext/remote/vllm.py", "unique_ext/remote/factory.py", "unique_ext/remote/replay.py", "unique_ext/control/__init__.py", "unique_ext/control/loop.py", "unique_ext/control/sources.py", "unique_ext/control/app.py", "tests/test_trispace.py", "docs/ADR-003-TRI-SPACE.md"] EXTRA = ["tests/test_learning.py", "tests/test_ext.py", "tests/test_local.py", "scripts/build_release.py", "docs/ADR-004-LOCAL.md", "docs/SYMBIOZA.md", "tests/test_geometry.py", "tests/test_symbioza.py", "tests/test_selfint.py", "tests/test_sky_sync.py", "tests/test_frontend.py"] def normalized_hash(text: str) -> str: # identique à unique_ext.updater.normalized_hash text = text.replace("\r\n", "\n").lstrip(" \t") return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] def _tree(rel_dir: str, suffixes: tuple[str, ...]) -> list[str]: return sorted(str(p.relative_to(ROOT)) for p in (ROOT / rel_dir).rglob("*") if p.is_file() and "__pycache__" not in p.parts and p.suffix in suffixes) def runtime_files() -> list[str]: ext = _tree("unique_ext", (".py", ".md")) web = _tree(WEB_DIR, WEB_SUFFIXES) return CORE + ext + web def build_manifest(version: str) -> dict: return {"version": version, "files": {rel: normalized_hash((ROOT / rel).read_text(encoding="utf-8")) for rel in runtime_files()}, "removed": [r for r in REMOVED if not (ROOT / r).exists()]} def check() -> list[str]: man = json.loads((ROOT / MANIFEST).read_text(encoding="utf-8")) problems = [f"{rel} absent du manifest" for rel in runtime_files() if rel not in man["files"]] for rel, h in sorted(man["files"].items()): p = ROOT / rel if not p.exists(): problems.append(f"{rel} manquant") elif normalized_hash(p.read_text(encoding="utf-8")) != h: problems.append(f"{rel} différent du manifest {man['version']}") return problems def main(argv: list[str] | None = None) -> int: sys.path.insert(0, str(ROOT)) import unique_ext major_minor = ".".join(unique_ext.__version__.split(".")[:2]) ap = argparse.ArgumentParser() ap.add_argument("--version", default=f"v{major_minor}") ap.add_argument("--out", default=None, help="défaut : dist/unique-.zip") ap.add_argument("--check", action="store_true") ap.add_argument("--since", default=None, help="manifest.json de la version en place : zip des seuls fichiers changés") a = ap.parse_args(argv) if a.check: problems = check() print("\n".join(problems) or "arbre conforme au manifest") return 1 if problems else 0 man = build_manifest(a.version) (ROOT / MANIFEST).write_text(json.dumps(man, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") out = Path(a.out) if a.out else ROOT / "dist" / f"unique-{a.version}.zip" out.parent.mkdir(parents=True, exist_ok=True) names = sorted(set(runtime_files() + [MANIFEST] + [e for e in EXTRA if (ROOT / e).exists()])) if a.since: # mise à jour : seulement ce qui a changé (l'updater vérifie que le reste est déjà en place) before = json.loads(Path(a.since).read_text(encoding="utf-8")).get("files", {}) names = [n for n in names if n == MANIFEST or n in EXTRA or before.get(n) != man["files"].get(n)] with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: for rel in names: z.write(ROOT / rel, rel) print(f"{out} : {len(names)} fichiers, version {a.version}") for rel in names: print(f" {rel}") return 0 if __name__ == "__main__": raise SystemExit(main())