"""Root project entity — what a Studio ``.appack`` package contains. A ``ProjectConfig`` is the unit of work in the Studio: one customer project = one configured vessel. It serialises to YAML or JSON for transport into the firmware build pipeline. """ from __future__ import annotations import json from datetime import datetime, timezone from pathlib import Path from typing import Any import yaml from pydantic import BaseModel, ConfigDict, Field from arautopilot.core.ids import ProjectId, new_project_id from arautopilot.core.vessel_config import VesselConfig from arautopilot.version import __version__ class ProjectConfig(BaseModel): """Root configuration object persisted to disk and shipped as ``.appack``.""" model_config = ConfigDict(extra="forbid", validate_assignment=True) schema_version: str = Field(default="0.1.0", pattern=r"^\d+\.\d+\.\d+$") """Project file schema version (independent of package ``__version__``).""" package_version: str = Field(default=__version__) """Version of ``arautopilot`` that produced this file (informational).""" project_id: ProjectId = Field(default_factory=new_project_id) client_name: str = Field(min_length=1, max_length=120) project_name: str = Field(min_length=1, max_length=120) notes: str = Field(default="", max_length=2000) vessel: VesselConfig created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) modified_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) # --- Serialisation ------------------------------------------------------- def to_dict(self) -> dict[str, Any]: """Return a JSON-ready dict (datetimes as ISO 8601 strings).""" return self.model_dump(mode="json") def to_json(self, *, indent: int | None = 2) -> str: """Serialise to JSON text.""" return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False) def to_yaml(self) -> str: """Serialise to YAML text (block style, sorted keys disabled to preserve schema order).""" return yaml.safe_dump( self.to_dict(), sort_keys=False, default_flow_style=False, allow_unicode=True, ) def save_yaml(self, path: Path | str) -> Path: """Write the project to ``path`` as YAML and return the resolved path.""" p = Path(path) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(self.to_yaml(), encoding="utf-8") return p def save_json(self, path: Path | str) -> Path: """Write the project to ``path`` as JSON and return the resolved path.""" p = Path(path) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(self.to_json(), encoding="utf-8") return p # --- Deserialisation ----------------------------------------------------- @classmethod def from_dict(cls, data: dict[str, Any]) -> "ProjectConfig": return cls.model_validate(data) @classmethod def from_json(cls, text: str) -> "ProjectConfig": return cls.model_validate(json.loads(text)) @classmethod def from_yaml(cls, text: str) -> "ProjectConfig": return cls.model_validate(yaml.safe_load(text)) @classmethod def load(cls, path: Path | str) -> "ProjectConfig": """Load from disk; format inferred from the file extension.""" p = Path(path) text = p.read_text(encoding="utf-8") suffix = p.suffix.lower() if suffix in (".yaml", ".yml"): return cls.from_yaml(text) if suffix == ".json": return cls.from_json(text) raise ValueError(f"Unsupported project file extension: {suffix!r}") def touch(self) -> "ProjectConfig": """Return a copy with ``modified_at`` refreshed to now (UTC).""" return self.model_copy(update={"modified_at": datetime.now(timezone.utc)})