""" Raíz del proyecto y serialización al formato ``.area``. Un ``.area`` es un archivo ZIP que contiene: :: project.json — metadata del proyecto y modelo serializado assets/ — recursos (íconos custom, capturas, fotografías) El formato JSON usa el modelo declarado por el resto de ``arelec.core`` y está versionado por el campo ``schema_version``. Los .area producidos por una versión del software pueden ser leídos por versiones posteriores (migraciones aditivas), nunca por anteriores. En Sprint 0 el modelo es mínimo (solo metadata) — los sub-modelos (ship, decks, appliances, panels, cables…) se agregan en sprints siguientes. """ from __future__ import annotations import json import zipfile from dataclasses import dataclass, field, asdict from datetime import datetime, timezone from pathlib import Path from typing import Any SCHEMA_VERSION = 1 @dataclass class ProjectMetadata: """Datos administrativos del proyecto.""" name: str = "Nuevo proyecto" author: str = "" company: str = "" notes: str = "" schema_version: int = SCHEMA_VERSION created_at: str = field( default_factory=lambda: datetime.now(timezone.utc).isoformat() ) modified_at: str = field( default_factory=lambda: datetime.now(timezone.utc).isoformat() ) @dataclass class Project: """ Raíz del modelo de datos. Sprint 0 contiene solo metadata; los sub-modelos (ship, decks, appliances, panels, cable_runs, batteries, etc.) se acoplan a esta clase en sprints siguientes. La instancia se serializa íntegra como ``project.json`` dentro del ZIP ``.area``. """ metadata: ProjectMetadata = field(default_factory=ProjectMetadata) # Sub-modelos futuros se irán agregando aquí: # ship: Ship | None = None # decks: list[Deck] = field(default_factory=list) # appliances: list[Appliance] = field(default_factory=list) # batteries: list[BatteryBank] = field(default_factory=list) # panels: list[Panel] = field(default_factory=list) # cable_runs: list[CableRun] = field(default_factory=list) # ── Serialization ─────────────────────────────────────────────────────── def to_json(self) -> dict[str, Any]: """Convertir a dict serializable (JSON-safe).""" return asdict(self) @classmethod def from_json(cls, data: dict[str, Any]) -> "Project": """Reconstruir desde dict cargado de ``project.json``.""" meta_dict = data.get("metadata", {}) return cls(metadata=ProjectMetadata(**meta_dict)) # ── .area I/O ─────────────────────────────────────────────────────────── def save(self, path: str | Path) -> None: """ Persistir el proyecto a ``path`` (extensión ``.area`` sugerida). Toca ``metadata.modified_at`` con la hora UTC actual antes de serializar. """ path = Path(path) self.metadata.modified_at = datetime.now(timezone.utc).isoformat() payload = json.dumps(self.to_json(), indent=2, ensure_ascii=False) # ``ZIP_DEFLATED`` para que los .area no crezcan absurdo en proyectos # grandes — el contenido es texto y comprime ~10×. with zipfile.ZipFile(path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: zf.writestr("project.json", payload) @classmethod def load(cls, path: str | Path) -> "Project": """ Cargar un proyecto desde ``path``. Lanza: - ``FileNotFoundError`` si el archivo no existe - ``zipfile.BadZipFile`` si el archivo no es ZIP válido - ``ValueError`` si ``schema_version`` es mayor que ``SCHEMA_VERSION`` (el .area fue creado por una versión más nueva del software) - ``KeyError`` si falta ``project.json`` en el ZIP """ path = Path(path) with zipfile.ZipFile(path, mode="r") as zf: with zf.open("project.json") as f: data = json.loads(f.read().decode("utf-8")) version = data.get("metadata", {}).get("schema_version", 1) if version > SCHEMA_VERSION: raise ValueError( f"El proyecto requiere schema_version={version}, " f"este software soporta hasta {SCHEMA_VERSION}. " f"Actualiza AR-ElecArrangement." ) return cls.from_json(data)